Skip to main content

newt_core/agentic/
compress.rs

1//! Compression v2 — summarize, don't discard (Step 18.4, issue #247).
2//!
3//! The shared pipeline behind both agentic loops' context-pressure triggers
4//! (the mid-loop count/token trim and the pre-send `send_budget` guard).
5//! Before this step those sites amputated the conversation middle into a
6//! one-line placeholder; the context baseline measured the consequence
7//! (`docs/testing/results/context-baseline-f0f4f6e.md` B6: 9/10 silently
8//! wrong answers under truncation — the task itself was discarded).
9//!
10//! Pipeline order (design: `docs/design/context-memory-hermes-learnings.md`
11//! §Phase 18):
12//!
13//! 1. **Structural prune** — [`crate::prune`]'s three passes (Step 18.3),
14//!    zero LLM cost. Recheck the budget; most invocations end here.
15//! 2. **Boundary computation** — head = system prompt + the original task
16//!    (anchored verbatim, so the task can never be summarized away); tail
17//!    protected by a TOKEN budget, not a message count (a count-based tail
18//!    with a few huge tool results defeats the pipeline); the most recent
19//!    user message is anchored into the tail (hermes #10896 — otherwise the
20//!    current request "effectively disappears from the active context"); the
21//!    cut is aligned past tool_call/result pairs so no orphan halves are
22//!    *created* (prevention, not just post-repair).
23//! 3. **LLM summary** of the middle via the injected summarizer, using the
24//!    `Summarizing` provider's lean section template plus the
25//!    verbatim-Active-Task rule, with [`redact_secrets`] applied to the
26//!    summarizer input — summaries persist and re-inject for the life of a
27//!    conversation, so credentials must never enter one.
28//! 4. **Assembly** — the summary message carries the
29//!    `[CONTEXT COMPACTION — REFERENCE ONLY]` prefix and the
30//!    `--- END OF CONTEXT SUMMARY ---` end marker (weak local models read a
31//!    verbatim task quote as fresh input without them — hermes #11475 /
32//!    #14521), then [`super::trim::repair_orphaned_tool_calls`] runs as the
33//!    post-hoc safety net, and a final aggressive prune (`keep_last: 0`)
34//!    fits a still-over result structurally rather than letting the backend
35//!    truncate it silently (the B6 failure shape: one giant tool round that
36//!    no boundary can split).
37//!
38//! **No summarizer** (eval / headless / `None`) or a failed summarizer →
39//! the static fallback marker ("Summary generation was unavailable. N
40//! message(s) were removed.") — the old placeholder-discard survives as
41//! exactly this path and only this path. A summarizer failure never aborts
42//! the turn.
43//!
44//! **Anti-thrash** ([`CompressState`], hoisted from the `Summarizing`
45//! provider to this shared path): when two consecutive compressions each
46//! reclaim <10%, auto-compression is disabled for the session and the user
47//! is told once; the budget guard stays hard — further over-budget rounds
48//! are refused rather than silently truncated.
49//!
50//! Summary *continuity* (prev-summary chaining, restore rehydration) is
51//! Step 18.5 — the seam is the summary message this module inserts.
52
53use serde_json::Value;
54use std::future::Future;
55use std::pin::Pin;
56use std::sync::OnceLock;
57
58use crate::prune::{prune, PruneConfig};
59
60use super::trim::{estimate_tokens, estimate_value_tokens, repair_orphaned_tool_calls};
61use crate::tokens::TokenEstimation;
62
63/// Future returned by an injected [`SummarizeFn`].
64pub type SummarizeFuture = Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send>>;
65
66/// The summarizer injected into the agentic loop (`ChatCtx::summarizer`):
67/// given the assembled (already-redacted) summary request, returns the
68/// summary text. Mirrors the `Summarizing` provider's `with_summarizer`
69/// injection, but async — the loop calls it mid-flight.
70pub type SummarizeFn = dyn Fn(String) -> SummarizeFuture + Send + Sync;
71
72/// Owning form of [`SummarizeFn`] for callers that build one per session.
73pub type Summarizer = Box<SummarizeFn>;
74
75/// Prefix marker on every compaction message. Weak local models otherwise
76/// treat the summary's verbatim task quote as a fresh instruction.
77pub const SUMMARY_PREFIX: &str = "[CONTEXT COMPACTION — REFERENCE ONLY]";
78
79/// End marker terminating every compaction message.
80pub const SUMMARY_END_MARKER: &str = "--- END OF CONTEXT SUMMARY ---";
81
82/// True when `m` is a compaction message this pipeline previously inserted
83/// (LLM summary and the static fallback both carry [`SUMMARY_PREFIX`]).
84/// Every user-role scan in the pipeline must consult this: anchoring the
85/// boundary on the pipeline's own marker was the F1 self-poisoning bug —
86/// from the second compression of a session on, the tail pinned to the
87/// previous summary, the middle went empty, the message count could never
88/// shrink, and the aggressive fit pass destroyed every fresh tool result
89/// before the model saw it.
90pub(crate) fn is_compaction_message(m: &Value) -> bool {
91    m["content"]
92        .as_str()
93        .is_some_and(|c| c.starts_with(SUMMARY_PREFIX))
94}
95
96/// String form of [`is_compaction_message`] for callers holding plain text
97/// instead of wire messages — the `Summarizing` provider's history entries
98/// and restored turn records (Step 18.5, #247).
99pub(crate) fn is_compaction_text(content: &str) -> bool {
100    content.starts_with(SUMMARY_PREFIX)
101}
102
103/// Hard minimum number of tail messages kept verbatim (hermes's floor) —
104/// even when the token-budgeted walk would protect fewer.
105const TAIL_MIN_MESSAGES: usize = 3;
106
107/// Per-message cap (chars) on content rendered into the summary request.
108const SUMMARY_INPUT_MSG_CAP: usize = 2_000;
109
110/// Relative reclaim fraction below which a compression looks ineffective. Now
111/// only ONE of several budget-aware effectiveness tests (see `record`).
112const THRASH_MIN_SAVINGS: f32 = 0.10;
113/// A pass also counts as effective if it shrank the over-budget GAP by at least
114/// this fraction — on a tight budget the irreducible head+tail dominates, so the
115/// *relative* reclaim looks small even when real work was done (#661 wedge).
116const GAP_MIN_PROGRESS: f32 = 0.25;
117/// ...or if it reclaimed at least this many tokens outright.
118const ABS_MIN_RECLAIM_TOKENS: usize = 200;
119
120// ---------------------------------------------------------------------------
121// Anti-thrash state
122// ---------------------------------------------------------------------------
123
124/// Session-scoped compression accounting (anti-thrash). Owned by the caller
125/// across turns (the TUI keeps one per session, like `NoteNudge`) and lent
126/// to the loop per call; headless callers may pass `None` and get a fresh
127/// per-turn state.
128#[derive(Debug)]
129pub struct CompressState {
130    /// Reclaim fractions of the last two attempted compressions (for display).
131    last_savings: [f32; 2],
132    /// Whether each of the last two passes was *effective* (budget-aware — see
133    /// [`record`](Self::record)). The strike/disable decision reads this, not
134    /// `last_savings`.
135    last_effective: [bool; 2],
136    attempts: usize,
137    disabled: bool,
138    notified: bool,
139    /// One-time latch for the fail-open notice (Step 20.3), kept separate
140    /// from `notified` so the over-budget-dispatch message and the
141    /// compression-disabled message each surface at most once.
142    failopen_notified: bool,
143}
144
145impl Default for CompressState {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151impl CompressState {
152    pub fn new() -> Self {
153        Self {
154            last_savings: [1.0, 1.0],
155            last_effective: [true, true],
156            attempts: 0,
157            disabled: false,
158            notified: false,
159            failopen_notified: false,
160        }
161    }
162
163    /// Record one attempted compression's before/after estimate against the
164    /// `budget` it was trying to reach. Two consecutive **ineffective** passes
165    /// disable auto-compression for the session.
166    ///
167    /// Effectiveness is **budget-aware** (#661): a pass is effective if it
168    /// reached fit, OR shrank the over-budget gap by ≥[`GAP_MIN_PROGRESS`], OR
169    /// reclaimed ≥[`ABS_MIN_RECLAIM_TOKENS`] outright, OR cleared the relative
170    /// [`THRASH_MIN_SAVINGS`] bar. On a tight budget the irreducible head+tail
171    /// fills most of the window, so the old relative-only test scored real work
172    /// as `<10%` and disabled compression exactly when it mattered most.
173    fn record(&mut self, tokens_before: usize, tokens_after: usize, budget: usize) {
174        let relative = if tokens_before > 0 {
175            1.0 - (tokens_after as f32 / tokens_before as f32)
176        } else {
177            0.0
178        };
179        let gap_before = tokens_before.saturating_sub(budget);
180        let gap_after = tokens_after.saturating_sub(budget);
181        let effective = tokens_after <= budget
182            || (gap_before > 0
183                && (gap_after as f32) <= (gap_before as f32) * (1.0 - GAP_MIN_PROGRESS))
184            || tokens_before.saturating_sub(tokens_after) >= ABS_MIN_RECLAIM_TOKENS
185            || relative >= THRASH_MIN_SAVINGS;
186        self.last_savings = [self.last_savings[1], relative];
187        self.last_effective = [self.last_effective[1], effective];
188        self.attempts += 1;
189        if self.attempts >= 2 && !self.last_effective[0] && !self.last_effective[1] {
190            self.disabled = true;
191        }
192    }
193
194    /// True once anti-thrash has disabled auto-compression for this
195    /// conversation (read by callers surfacing state and by the
196    /// conversation-boundary reset tests).
197    pub fn is_disabled(&self) -> bool {
198        self.disabled
199    }
200
201    /// Re-arm after a conversation boundary. The anti-thrash notice promises
202    /// "start a new conversation to reset" — the TUI makes that true by
203    /// calling this from `/new` and `/conversation restore` (F4).
204    pub fn reset(&mut self) {
205        *self = Self::new();
206    }
207
208    /// Latch the disabled state as if anti-thrash had fired — for tests that
209    /// assert conversation-boundary resets without driving two poor passes.
210    #[doc(hidden)]
211    pub fn latch_disabled_for_tests(&mut self) {
212        self.disabled = true;
213        self.notified = true;
214    }
215
216    /// Read-only counters snapshot for display surfaces (`/memory`,
217    /// Step 18.6, #247). Pure projection of existing state — no new
218    /// accounting lives here.
219    pub fn counters(&self) -> CompressCounters {
220        // Strikes: how many of the most recent recorded compressions were
221        // consecutively ineffective (<10% reclaim) — 0, 1, or 2; two is the
222        // latch condition. The [1.0, 1.0] sentinel never counts because a
223        // slot only holds a real figure once an attempt recorded into it.
224        let strikes = if self.attempts == 0 || self.last_effective[1] {
225            0
226        } else if self.attempts >= 2 && !self.last_effective[0] {
227            2
228        } else {
229            1
230        };
231        CompressCounters {
232            compressions: self.attempts,
233            strikes,
234            disabled: self.disabled,
235            last_reclaim: (self.attempts > 0).then_some(self.last_savings[1]),
236        }
237    }
238
239    /// One-time user-facing notice, produced when anti-thrash disables
240    /// compression. Subsequent calls return `None`.
241    fn take_notice(&mut self) -> Option<String> {
242        if self.disabled && !self.notified {
243            self.notified = true;
244            Some(
245                "context compression was ineffective twice in a row — auto-compression \
246                 is disabled for this session; start a new conversation to reset"
247                    .to_string(),
248            )
249        } else {
250            None
251        }
252    }
253
254    /// One-time fail-open notice (Step 20.3): compression is latched off and
255    /// the context exceeds the budget, but that budget rests on the
256    /// proven-good high-water mark alone — no authoritative window is known
257    /// for this model. Rather than refuse (which would starve the very
258    /// acceptance evidence that raises the HWM), the send proceeds and the
259    /// backend rules. Surfaced once per session.
260    fn take_failopen_notice(&mut self) -> Option<String> {
261        if !self.failopen_notified {
262            self.failopen_notified = true;
263            Some(
264                "context exceeds the proven-good budget, but no authoritative window \
265                 limit is known for this model — dispatching over budget and letting \
266                 the backend decide; an accepted size raises the learned budget"
267                    .to_string(),
268            )
269        } else {
270            None
271        }
272    }
273}
274
275/// Read-only snapshot of a session's compression accounting, surfaced by the
276/// TUI's `/memory` (Step 18.6, #247). Plain data so display code and its
277/// tests can build arbitrary states without driving the pipeline.
278#[derive(Debug, Clone, Copy, PartialEq)]
279pub struct CompressCounters {
280    /// Compressions recorded this session: the loop's hard-budget passes
281    /// plus fired `/compress` runs (both feed [`CompressState`]).
282    pub compressions: usize,
283    /// Consecutive ineffective (<10% reclaim) recent compressions — 0, 1,
284    /// or 2. Two latches `disabled`.
285    pub strikes: usize,
286    /// Anti-thrash latch: auto-compression is disabled for the session.
287    /// `/new` (and `/conversation restore`) re-arm it — F4.
288    pub disabled: bool,
289    /// Reclaim fraction (0.0–1.0) of the most recent recorded compression;
290    /// `None` before any compression recorded.
291    pub last_reclaim: Option<f32>,
292}
293
294// ---------------------------------------------------------------------------
295// Trigger
296// ---------------------------------------------------------------------------
297
298/// What [`compression_trigger`] decided for this round.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub(crate) struct CompressTrigger {
301    /// Message-space token budget (chars/4 estimate currency).
302    pub budget: usize,
303    /// Message-count ceiling, set only by the count trigger (structural
304    /// pruning alone can never satisfy it — pruning never removes messages).
305    pub max_messages: Option<usize>,
306    /// True when a token trigger set `budget` — the hard correctness guard
307    /// that consults and feeds anti-thrash. False for count-only (VRAM
308    /// guard) firings, whose aim-to-halve budget does neither (F2).
309    pub hard_budget: bool,
310}
311
312/// Decide whether compression fires this round, and with what message-space
313/// token budget. One decision serves all three triggers:
314///
315/// - the mid-loop message-count threshold (the original VRAM guard),
316/// - the mid-loop token threshold (issue #223),
317/// - the pre-send `send_budget` guard (`max_ok_input` / `safe_context`).
318///
319/// `current_tokens` is the caller's truthful context figure
320/// (prompt-tokens-preferred, Step 18.1) and includes tool-schema tokens; the
321/// guard's budget therefore has `tool_tokens` subtracted to land back in
322/// message-only space (the same arithmetic the old trim used). The tightest
323/// fired budget wins. `message_tokens` is the caller's chars/4 estimate of
324/// the message list alone — the currency the pipeline compares its budget
325/// against — and prices the count-only trigger's aim-to-halve budget (F1).
326pub(crate) fn compression_trigger(
327    len: usize,
328    current_tokens: usize,
329    message_tokens: usize,
330    count_threshold: usize,
331    token_threshold: Option<usize>,
332    send_budget: Option<usize>,
333    tool_tokens: usize,
334) -> Option<CompressTrigger> {
335    // A zero token budget from config means DISABLED, not "compress to zero
336    // every round" — the old `trim_to_token_budget` zero-is-noop contract,
337    // re-homed here (F3).
338    let token_threshold = token_threshold.filter(|&b| b > 0);
339    let send_budget = send_budget.filter(|&b| b > 0);
340
341    let count_fired = len > count_threshold;
342    let token_fired = token_threshold.is_some_and(|b| current_tokens > b);
343    let guard_fired = send_budget.is_some_and(|b| current_tokens > b);
344    if !(count_fired || token_fired || guard_fired) {
345        return None;
346    }
347    let mut budget = usize::MAX;
348    if token_fired {
349        budget = budget.min(token_threshold.unwrap_or(usize::MAX));
350    }
351    if guard_fired {
352        budget = budget.min(
353            send_budget
354                .unwrap_or(usize::MAX)
355                .saturating_sub(tool_tokens),
356        );
357    }
358    let hard_budget = budget != usize::MAX;
359    if !hard_budget {
360        // Count-only trigger: no token target configured — aim to halve, in
361        // MESSAGE-token space. Halving `current_tokens` (which includes
362        // tool-schema and, when anchored, chat-template tokens no message
363        // compression can ever reclaim) made the target cross-currency
364        // unreachable, so the aggressive fit pass fired every round (F1).
365        budget = message_tokens / 2;
366    }
367    Some(CompressTrigger {
368        budget,
369        max_messages: count_fired.then_some(count_threshold / 2),
370        hard_budget,
371    })
372}
373
374// ---------------------------------------------------------------------------
375// The pipeline
376// ---------------------------------------------------------------------------
377
378/// One compression request from a loop call site.
379pub(crate) struct CompressRequest<'a> {
380    pub messages: &'a [Value],
381    /// Message-space token budget (chars/4 estimate currency) the result
382    /// should fit.
383    pub budget: usize,
384    /// Message-count ceiling (set by the mid-loop count trigger). When
385    /// `Some`, the structural prune alone can never satisfy the request.
386    pub max_messages: Option<usize>,
387    /// The original task — anchored verbatim into the summary request.
388    pub task: &'a str,
389    /// True when `budget` rests on an authoritative ceiling (Step 20.3) — a
390    /// believed/declared window, the `num_ctx` ceiling, a configured token
391    /// threshold, or a cw-400 cap. False when it rests on the proven-good
392    /// high-water mark alone, in which case anti-thrash dispatches over budget
393    /// (`DispatchedOverBudget`) instead of refusing. `true` for every non-guard
394    /// caller (cw-400 recovery, overflow retry, `/compress`, memory) —
395    /// preserving today's refuse-on-exceed behavior there.
396    pub authoritative: bool,
397    /// True when `budget` came from a token trigger (mid-loop token
398    /// threshold, send-budget guard, cw-400 recovery, overflow retry) — the
399    /// hard correctness guard that consults and feeds anti-thrash. Count-only
400    /// (VRAM guard) requests pass false and do neither (F2): their soft
401    /// aim-to-halve budget must never latch the disable switch or convert a
402    /// healthy session into a refused send.
403    pub hard_budget: bool,
404    /// Optional user-supplied focus topic (`/compress <focus>`, Step 18.6):
405    /// threaded into the summary request as emphasis guidance. Redacted with
406    /// the same [`redact_secrets`] pass as the rendered middle — a user can
407    /// type a credential into the focus. The loop's automatic triggers pass
408    /// `None`.
409    pub focus: Option<&'a str>,
410    /// The token-estimation heuristic setting (`[context.estimation]`), threaded
411    /// so every estimate + the budget→chars cap conversion share one ratio.
412    pub est: crate::tokens::TokenEstimation,
413    /// Floor (chars) for the summarizer input cap — `[context]
414    /// summary_input_cap_floor_chars`. A tight budget would otherwise starve the
415    /// summarizer of material.
416    pub summary_input_cap_floor_chars: usize,
417    /// Session compaction store (#661 group B). When `Some`, the evicted middle
418    /// span is stored (redacted) and a `compaction:<id>` retrieval handle is
419    /// named in the marker — progressive disclosure. `None` (headless / off)
420    /// keeps today's lossy-only behavior.
421    pub compaction_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
422}
423
424impl<'a> CompressRequest<'a> {
425    /// A user-initiated request (the TUI's `/compress`, Step 18.6). The user
426    /// asked for compression NOW, with or without token pressure, so the
427    /// budget is aim-to-halve in message-token space — the count trigger's
428    /// exact pricing (F1) — and `hard_budget` is false: like a count-only
429    /// firing, a manual run neither consults the anti-thrash latch (an
430    /// explicit ask still runs after auto-compression is disabled) nor lets
431    /// the pipeline's internal accounting treat it as the correctness guard.
432    /// Effectiveness accounting for fired manual runs is the caller's call —
433    /// [`compress_user_initiated`] records them.
434    pub(crate) fn user_initiated(
435        messages: &'a [Value],
436        task: &'a str,
437        focus: Option<&'a str>,
438        est: TokenEstimation,
439        summary_input_cap_floor_chars: usize,
440    ) -> Self {
441        Self {
442            messages,
443            budget: estimate_tokens(messages, est) / 2,
444            max_messages: None,
445            task,
446            hard_budget: false,
447            // Moot for a soft (`hard_budget: false`) manual run — it never
448            // reaches the refuse branch — but kept truthful (Step 20.3).
449            authoritative: true,
450            focus,
451            est,
452            summary_input_cap_floor_chars,
453            // The manual `/compress` path stays lossy-only for the MVP; the
454            // auto-loop is the progressive-disclosure surface (#661 group B).
455            compaction_store: None,
456        }
457    }
458}
459
460/// What the pipeline did, in escalation order.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub(crate) enum CompressAction {
463    /// Already within budget by the pipeline's own estimate — untouched.
464    Fit,
465    /// Structural pruning alone sufficed (or was all that applied).
466    Pruned,
467    /// Middle replaced with an LLM summary.
468    Summarized,
469    /// Middle replaced with the static fallback marker (no summarizer, or
470    /// the summarizer failed).
471    StaticFallback,
472    /// Anti-thrash disabled compression while the list exceeds an
473    /// *authoritative* budget (a believed/declared window or a cw-400 cap):
474    /// the caller must refuse the send rather than silently truncate.
475    Refused,
476    /// Anti-thrash disabled compression while the list exceeds a
477    /// *non-authoritative* budget — one resting on the proven-good
478    /// high-water mark alone, with no believed ceiling (Step 20.3). The HWM
479    /// is a floor of known-good, never a cap; refusing here would starve the
480    /// acceptance evidence that raises it. The caller dispatches over budget
481    /// and lets the backend be the authority (fail open).
482    DispatchedOverBudget,
483}
484
485impl CompressAction {
486    /// Short human description for the compression notice.
487    pub(crate) fn describe(self) -> &'static str {
488        match self {
489            Self::Fit => "no change",
490            Self::Pruned => "structural prune",
491            Self::Summarized => "prune + summary",
492            Self::StaticFallback => "prune + static marker",
493            Self::Refused => "refused",
494            Self::DispatchedOverBudget => "over budget — dispatched",
495        }
496    }
497}
498
499/// Result of one [`compress`] run.
500pub(crate) struct CompressOutcome {
501    pub messages: Vec<Value>,
502    pub action: CompressAction,
503    /// True when `messages` differs from the input.
504    pub fired: bool,
505    pub tokens_before: usize,
506    pub tokens_after: usize,
507    /// One-time anti-thrash notice for the display path, when it just fired.
508    pub notice: Option<String>,
509}
510
511/// Run the compression pipeline: prune → boundary → redacted summary →
512/// marker assembly → repair (+ final structural fit pass). Infallible by
513/// design — a summarizer failure degrades to the static marker; only
514/// [`CompressAction::Refused`] asks the caller to stop.
515pub(crate) async fn compress(
516    req: CompressRequest<'_>,
517    summarizer: Option<&SummarizeFn>,
518    state: &mut CompressState,
519) -> CompressOutcome {
520    let tokens_before = estimate_tokens(req.messages, req.est);
521    // Anti-thrash protects the hard token budget (the correctness guard);
522    // count-only invocations (the VRAM guard) neither consult nor feed it —
523    // `hard_budget` carries the trigger kind so this holds even when the
524    // count trigger's soft aim-to-halve budget happens to be exceeded (F2).
525    let tokens_over_entry = req.hard_budget && tokens_before > req.budget;
526    let over = |tokens: usize, len: usize| {
527        tokens > req.budget || req.max_messages.is_some_and(|m| len > m)
528    };
529
530    if !over(tokens_before, req.messages.len()) {
531        return CompressOutcome {
532            messages: req.messages.to_vec(),
533            action: CompressAction::Fit,
534            fired: false,
535            tokens_before,
536            tokens_after: tokens_before,
537            notice: None,
538        };
539    }
540    // #6 (D, #661): a forced static-marker compaction replaces the dead-end
541    // Refused when compression is latched off but we're over an authoritative
542    // hard budget — set here, honored in the assembly + the post-assembly check.
543    let mut force_marker = false;
544    if state.disabled && req.hard_budget {
545        if tokens_over_entry {
546            if req.authoritative {
547                // #6: do NOT dead-end on Refused. A static-marker compaction
548                // always reclaims the whole middle deterministically (no
549                // summarizer needed), keeping head+task+tail intact under budget
550                // — strictly better than erroring the turn and forcing /new. Force
551                // that path below; refuse ONLY if even head+tail alone exceed the
552                // budget (truly irreducible), checked after assembly.
553                force_marker = true;
554            } else {
555                // Step 20.3: the budget rests on the proven-good high-water mark
556                // alone — no authoritative window is known for this model (the
557                // cloud / no-`/api/show` case). The HWM is a floor of known-good,
558                // not a cap; refusing here is the death spiral — it discards the
559                // very acceptance evidence that would raise the HWM out of the
560                // hole. Fail OPEN: dispatch over budget and let the backend rule.
561                return CompressOutcome {
562                    messages: req.messages.to_vec(),
563                    action: CompressAction::DispatchedOverBudget,
564                    fired: false,
565                    tokens_before,
566                    tokens_after: tokens_before,
567                    notice: state.take_failopen_notice(),
568                };
569            }
570        } else {
571            // A hard trigger fired but the message estimate fits its budget
572            // (e.g. mixed with the count trigger): compression is disabled —
573            // pass through unchanged.
574            return CompressOutcome {
575                messages: req.messages.to_vec(),
576                action: CompressAction::Fit,
577                fired: false,
578                tokens_before,
579                tokens_after: tokens_before,
580                notice: state.take_notice(),
581            };
582        }
583    }
584
585    // (1) Structural prune — zero LLM cost (Step 18.3's passes).
586    let pruned = prune(req.messages, &PruneConfig::default());
587    let prune_changed = pruned.chars_reclaimed > 0;
588    let pruned = pruned.messages;
589    let after_prune = estimate_tokens(&pruned, req.est);
590    if !over(after_prune, pruned.len()) {
591        if tokens_over_entry {
592            state.record(tokens_before, after_prune, req.budget);
593        }
594        return CompressOutcome {
595            messages: pruned,
596            action: CompressAction::Pruned,
597            fired: prune_changed,
598            tokens_before,
599            tokens_after: after_prune,
600            notice: state.take_notice(),
601        };
602    }
603
604    // (2) Boundary: head + token-budgeted (and, for the count trigger,
605    // count-capped) tail, last-user anchored, tool-pair aligned.
606    let boundary = compute_boundary(&pruned, req.budget, req.max_messages, req.est);
607    let middle = &pruned[boundary.head..boundary.tail_start];
608
609    let (mut assembled, mut action) = if middle.is_empty() {
610        // Nothing summarizable between the protected head and tail.
611        (pruned.clone(), CompressAction::Pruned)
612    } else {
613        // (3) LLM summary of the middle, redaction applied to the input.
614        // #6 (D): the forced-marker path skips the (disabled) summarizer entirely
615        // and uses the deterministic static marker below.
616        let body = if force_marker {
617            None
618        } else {
619            match summarizer {
620                Some(f) => {
621                    // Cap each summary request so it cannot blow the summarizer's
622                    // context window — per-message caps alone do not bound the total
623                    // (F5). The cap is the compression budget in chars (4 chars/
624                    // token): the budget is what the *conversation* must fit after
625                    // compression, so a request of the same order fits any window
626                    // the compressed conversation will. Floored at 8 KiB so tight
627                    // budgets still give the summarizer enough material. Step 24.4
628                    // (#559): a middle larger than the cap is summarized in bounded
629                    // chunks and hierarchically reduced — every request stays under
630                    // the cap (no OOM) and no middle message is dropped.
631                    let middle_cap = req
632                        .est
633                        .chars_for_tokens(req.budget)
634                        .max(req.summary_input_cap_floor_chars);
635                    summarize_middle(f, req.task, middle, middle_cap, req.focus).await
636                }
637                None => None,
638            }
639        };
640        let action = if body.is_some() {
641            CompressAction::Summarized
642        } else {
643            CompressAction::StaticFallback
644        };
645        let mut body = body.unwrap_or_else(|| static_fallback_text(middle.len()));
646        // #319: the summary is prose and does NOT preserve verbatim file
647        // contents. A coding model that recalls an API/signature from the
648        // summary will hallucinate it. Name the files read in the compacted
649        // span with an explicit re-read directive so the model treats its
650        // memory of them as stale and re-reads instead of inventing.
651        if let Some(crumb) = reread_breadcrumb(middle) {
652            body.push_str("\n\n");
653            body.push_str(&crumb);
654        }
655        // #661 group B (progressive disclosure): store the verbatim (redacted)
656        // evicted middle in the session compaction store and name its handle, so
657        // the model can losslessly recover an exact detail the lossy summary
658        // dropped — `memory_fetch("compaction:<id>")`. Redact-on-store (the same
659        // closed `redact_secrets` table `spill:` uses): only the redacted span is
660        // ever retained. The summary is demoted from sole replacement to a
661        // catalog card over a retrievable span.
662        if let Some(store) = req.compaction_store {
663            let verbatim: String = middle
664                .iter()
665                .map(render_message)
666                .collect::<Vec<_>>()
667                .join("\n");
668            let id = store.store(redact_secrets(&verbatim));
669            body.push_str(&format!(
670                "\n\n[the full verbatim text of this compacted span is retrievable with \
671                 memory_fetch(\"compaction:{id}\") — use it to recover an exact detail \
672                 this summary dropped, instead of guessing]"
673            ));
674        }
675        // (4) Assembly with the REFERENCE-ONLY prefix + end marker.
676        let mut out = Vec::with_capacity(boundary.head + 1 + (pruned.len() - boundary.tail_start));
677        out.extend_from_slice(&pruned[..boundary.head]);
678        out.push(summary_message(&body));
679        out.extend_from_slice(&pruned[boundary.tail_start..]);
680        (out, action)
681    };
682
683    // Post-hoc safety net: never ship an orphaned tool_call/result half.
684    repair_orphaned_tool_calls(&mut assembled);
685
686    // Final structural fit pass: when the protected tail itself blows the
687    // budget (B6's shape — one giant tool round), one-line the AGED part
688    // rather than letting the backend silently truncate the head (and the
689    // task) away. The trailing tool group — results the model has not seen
690    // yet — is NEVER pruned here (F1c): the old `keep_last: 0` destroyed
691    // every fresh result from the second compression of a session on,
692    // leaving the model unable to read anything. An over-budget dispatch is
693    // recoverable (cw-400 recovery / overflow retry); a destroyed fresh
694    // result is not. The group is derived from the last assistant message
695    // carrying `tool_calls` — NOT by counting trailing `role == "tool"`
696    // messages, which any interleaved user message (the read-only nudge, a
697    // compaction notice) zeroed, flooring `keep_last` at 2 and one-lining
698    // older unseen results for a round (#270).
699    if estimate_tokens(&assembled, req.est) > req.budget {
700        let aggressive = prune(
701            &assembled,
702            &PruneConfig {
703                keep_last: trailing_tool_group_len(&assembled).max(2),
704                ..PruneConfig::default()
705            },
706        );
707        if aggressive.chars_reclaimed > 0 {
708            assembled = aggressive.messages;
709            if action == CompressAction::Fit {
710                action = CompressAction::Pruned;
711            }
712        }
713        // #285: under a HARD budget (the window-correctness guard), the
714        // F1c protection and the window are in direct tension when the
715        // trailing group BY ITSELF exceeds what is left of the budget after
716        // the (already maximally pruned) head + summary. Reclaim WITHIN the
717        // group — newest result kept whole, older members one-lined oldest
718        // first — instead of always shipping over-window into a silent
719        // backend truncation. Soft (count-only / `/compress`) budgets never
720        // reach this: missing an aim-to-halve target is not a correctness
721        // problem, so the F1c protection stays absolute there.
722        if req.hard_budget
723            && estimate_tokens(&assembled, req.est) > req.budget
724            && reclaim_within_trailing_group(&mut assembled, req.budget, req.est)
725            && action == CompressAction::Fit
726        {
727            action = CompressAction::Pruned;
728        }
729    }
730
731    // #6 (D): the forced-marker path refuses ONLY when even head+tail alone still
732    // exceed the budget (truly irreducible — the loop must still terminate rather
733    // than dispatch an infinite over-budget send). Otherwise the marker compaction
734    // is a valid fit, returned below instead of erroring the turn.
735    if force_marker && estimate_tokens(&assembled, req.est) > req.budget {
736        return CompressOutcome {
737            messages: req.messages.to_vec(),
738            action: CompressAction::Refused,
739            fired: false,
740            tokens_before,
741            tokens_after: tokens_before,
742            notice: state.take_notice(),
743        };
744    }
745
746    let tokens_after = estimate_tokens(&assembled, req.est);
747    let fired =
748        prune_changed || assembled.len() != req.messages.len() || tokens_after != tokens_before;
749    // A forced marker compaction (already latched off) does not feed effectiveness
750    // accounting — it is a guaranteed-fit fallback, not a measured pass.
751    if tokens_over_entry && !force_marker {
752        state.record(tokens_before, tokens_after, req.budget);
753    }
754    CompressOutcome {
755        messages: assembled,
756        action,
757        fired,
758        tokens_before,
759        tokens_after,
760        notice: state.take_notice(),
761    }
762}
763
764// ---------------------------------------------------------------------------
765// User-initiated compression (`/compress [focus]`, Step 18.6)
766// ---------------------------------------------------------------------------
767
768/// What a user-initiated [`compress_user_initiated`] run did — the public
769/// face of [`CompressOutcome`], with the message counts the honesty notice
770/// needs ("never claim savings that didn't happen").
771#[derive(Debug, Clone)]
772pub struct ManualCompressOutcome {
773    /// The assembled working set (equals the input when `fired` is false).
774    pub messages: Vec<Value>,
775    /// True when the pipeline actually changed the working set. False ⇒
776    /// "no compression possible" — the caller must not claim savings.
777    pub fired: bool,
778    pub messages_before: usize,
779    pub messages_after: usize,
780    /// chars/4 estimates over the message list (the pipeline's currency).
781    pub tokens_before: usize,
782    pub tokens_after: usize,
783    /// What the pipeline did, e.g. `"prune + summary"` (the LLM summarizer
784    /// ran) vs `"prune + static marker"` (no summarizer / it failed) vs
785    /// `"structural prune"` — [`CompressAction::describe`]'s wording, so the
786    /// manual notice and the loop's notice can never drift apart.
787    pub how: &'static str,
788    /// One-time anti-thrash notice, when this run just latched the disable.
789    pub notice: Option<String>,
790}
791
792/// Run the shared compression pipeline because the user asked (`/compress
793/// [focus]`, Step 18.6, #247) — the SAME prune → boundary → redacted summary
794/// → marker assembly the loop's triggers call, via
795/// [`CompressRequest::user_initiated`] (aim-to-halve, soft budget). No
796/// bespoke compression path.
797///
798/// Anti-thrash interplay: the soft request never *consults* the latch (an
799/// explicit ask still runs after auto-compression is disabled), but a fired
800/// run *records* its reclaim into `state` — hermes parity: manual passes
801/// feed effectiveness accounting, so `/memory`'s counters stay truthful and
802/// a genuinely useless summarizer still latches. A no-op run records
803/// nothing: an incompressible-because-tiny session must never strike out
804/// auto-compression for later.
805///
806/// The original-task anchor is derived from the working set itself (first
807/// real user message — a leading compaction message is not the task), the
808/// same rule the `Summarizing` provider applies.
809pub async fn compress_user_initiated(
810    messages: &[Value],
811    focus: Option<&str>,
812    summarizer: Option<&SummarizeFn>,
813    state: &mut CompressState,
814    est: crate::tokens::TokenEstimation,
815    summary_input_cap_floor_chars: usize,
816) -> ManualCompressOutcome {
817    let task = messages
818        .iter()
819        .find(|m| m["role"].as_str() == Some("user") && !is_compaction_message(m))
820        .and_then(|m| m["content"].as_str())
821        .unwrap_or_default()
822        .to_string();
823    let outcome = compress(
824        CompressRequest::user_initiated(messages, &task, focus, est, summary_input_cap_floor_chars),
825        summarizer,
826        state,
827    )
828    .await;
829    if outcome.fired {
830        // The manual (user-initiated) budget is aim-to-halve (tokens/2).
831        state.record(
832            outcome.tokens_before,
833            outcome.tokens_after,
834            outcome.tokens_before / 2,
835        );
836    }
837    let notice = outcome.notice.or_else(|| state.take_notice());
838    ManualCompressOutcome {
839        messages_before: messages.len(),
840        messages_after: outcome.messages.len(),
841        fired: outcome.fired,
842        tokens_before: outcome.tokens_before,
843        tokens_after: outcome.tokens_after,
844        how: outcome.action.describe(),
845        notice,
846        messages: outcome.messages,
847    }
848}
849
850// ---------------------------------------------------------------------------
851// Boundary computation
852// ---------------------------------------------------------------------------
853
854struct Boundary {
855    /// Protected head: `[0, head)` — leading system message(s) plus the
856    /// original task.
857    head: usize,
858    /// Protected tail: `[tail_start, len)`. The middle `[head, tail_start)`
859    /// is what gets summarized.
860    tail_start: usize,
861}
862
863/// Compute the protected head and the token-budgeted, anchored, pair-aligned
864/// protected tail. `max_messages` (the count trigger's ceiling) additionally
865/// caps the tail by count so the assembled `head + summary + tail` actually
866/// lands at or under the ceiling — a token-budgeted tail alone can swallow
867/// an entire small-message conversation and leave nothing to summarize.
868fn compute_boundary(
869    messages: &[Value],
870    budget: usize,
871    max_messages: Option<usize>,
872    est: TokenEstimation,
873) -> Boundary {
874    let head = head_len(messages);
875    let max_tail = max_messages.map(|m| m.saturating_sub(head + 1).max(1));
876
877    // Token-budgeted tail: walk backward accumulating estimates until ~25%
878    // of the budget is protected, with a hard minimum of TAIL_MIN_MESSAGES.
879    let tail_budget = (budget / 4).max(1);
880    let mut tail_start = messages.len();
881    let mut acc = 0usize;
882    let mut kept = 0usize;
883    while tail_start > head {
884        if max_tail.is_some_and(|m| kept >= m) {
885            break;
886        }
887        let t = estimate_value_tokens(&messages[tail_start - 1], est);
888        if kept >= TAIL_MIN_MESSAGES && acc + t > tail_budget {
889            break;
890        }
891        acc += t;
892        kept += 1;
893        tail_start -= 1;
894    }
895
896    // Last-user anchor: the most recent REAL user message is never
897    // summarized away (hermes #10896 — losing it loses the active request).
898    // The pipeline's own compaction messages are user-role but must never
899    // anchor: pinning the tail to the previous summary froze the boundary
900    // for the rest of the session (F1).
901    if let Some(last_user) = messages
902        .iter()
903        .rposition(|m| m["role"].as_str() == Some("user") && !is_compaction_message(m))
904    {
905        if last_user >= head {
906            tail_start = tail_start.min(last_user);
907        }
908    }
909
910    // Tool-pair boundary prevention: never start the tail inside a result
911    // group — pull the cut back to the assistant carrying the tool_calls so
912    // call/result pairs stay together (hermes `_align_boundary_backward`).
913    while tail_start > head && messages[tail_start]["role"].as_str() == Some("tool") {
914        tail_start -= 1;
915    }
916
917    // Count-goal recheck (F1d): the anchor (or pair alignment) may have
918    // extended the tail past the count trigger's ceiling, making
919    // `max_messages` unreachable — the trigger then re-fires every round
920    // and the summarizer runs per round for nothing. Re-apply the cap by
921    // advancing the cut; the current request still survives verbatim via
922    // the summary's Active-Task rule even when the anchored message lands
923    // in the middle. Then re-align so the cut never starts inside a result
924    // group (this can give back a few messages of slack — bounded by the
925    // group size, not unbounded growth).
926    if let Some(max_tail) = max_tail {
927        let cap_start = messages.len().saturating_sub(max_tail);
928        if tail_start < cap_start {
929            tail_start = cap_start;
930            while tail_start > head && messages[tail_start]["role"].as_str() == Some("tool") {
931                tail_start -= 1;
932            }
933        }
934    }
935
936    Boundary { head, tail_start }
937}
938
939/// Length of the protected head: every leading `system` message plus the
940/// first `user` message after them (the original task). A compaction
941/// message in that slot (a rehydrated history can start with one) is NOT
942/// the task and must stay summarizable.
943fn head_len(messages: &[Value]) -> usize {
944    let mut head = 0;
945    while head < messages.len() && messages[head]["role"].as_str() == Some("system") {
946        head += 1;
947    }
948    if head < messages.len()
949        && messages[head]["role"].as_str() == Some("user")
950        && !is_compaction_message(&messages[head])
951    {
952        head += 1;
953    }
954    head
955}
956
957// ---------------------------------------------------------------------------
958// Trailing-group protection (#270 / #285)
959// ---------------------------------------------------------------------------
960
961/// Length of the suffix the aggressive fit pass protects: from the LAST
962/// message carrying `tool_calls` (the assistant turn that issued the calls)
963/// through the end of the list — that turn, its fresh (unseen) results, and
964/// anything interleaved after them. `0` when nothing in the list ever
965/// called a tool.
966///
967/// Deriving the group by counting trailing `role == "tool"` messages was the
968/// #270 gap: the read-only-round nudge injects a `user` message immediately
969/// before the compression call site, the trailing count read zero,
970/// `keep_last` fell to its floor of 2, and every older unseen result in the
971/// fresh group was one-lined pre-dispatch for a round. Anchoring on the
972/// turn that ISSUED the calls makes the group immune to whatever lands
973/// after it (a nudge, a compaction notice). Only `tool_calls` is consulted
974/// — the loop appends the backend's `message` object verbatim, and a `role`
975/// field is not guaranteed on every wire dialect.
976fn trailing_tool_group_len(messages: &[Value]) -> usize {
977    messages
978        .iter()
979        .rposition(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()))
980        .map_or(0, |i| messages.len() - i)
981}
982
983/// #285 escape hatch for the F1c trailing-group protection: when the fresh
984/// trailing group BY ITSELF exceeds the budget remaining after everything
985/// before it (head + summary + already-one-lined aged remnants), no amount
986/// of out-of-group reclaim can fit the window — compression honestly reports
987/// "still over budget" and the backend then truncates the dispatch silently
988/// (B6's wrong-answer shape, measured in #284's gauntlet). Reclaim WITHIN
989/// the group instead: keep the NEWEST result whole, one-line older members
990/// oldest-first via the prune pass-2 machinery (the one-liner names the tool
991/// and file, so the model can re-read), stopping as soon as the list fits.
992///
993/// If even the newest result alone exceeds the budget the list stays over —
994/// the dispatch proceeds truthfully over budget (the loop's N2 notice
995/// reports real numbers); clipping inside a single result is out of scope.
996/// Returns true when any member was rewritten.
997fn reclaim_within_trailing_group(
998    assembled: &mut Vec<Value>,
999    budget: usize,
1000    est: TokenEstimation,
1001) -> bool {
1002    let group_len = trailing_tool_group_len(assembled);
1003    if group_len == 0 {
1004        return false;
1005    }
1006    let group_start = assembled.len() - group_len;
1007    let outside = estimate_tokens(&assembled[..group_start], est);
1008    let group_tokens = estimate_tokens(&assembled[group_start..], est);
1009    if group_tokens <= budget.saturating_sub(outside) {
1010        // The group fits in its share of the budget — the overage is not
1011        // the group's, so the F1c protection holds unconditionally.
1012        return false;
1013    }
1014    // Every group result EXCEPT the newest is a candidate, oldest first.
1015    let result_idxs: Vec<usize> = (group_start..assembled.len())
1016        .filter(|&i| assembled[i]["role"].as_str() == Some("tool"))
1017        .collect();
1018    let mut changed = false;
1019    for &i in result_idxs.iter().take(result_idxs.len().saturating_sub(1)) {
1020        // `keep_last` shields everything after index `i`, so exactly the
1021        // members up to and including `i` are exposed to the one-liner pass;
1022        // earlier iterations' rewrites are idempotent under re-pruning.
1023        let pass = prune(
1024            assembled,
1025            &PruneConfig {
1026                keep_last: assembled.len() - i - 1,
1027                ..PruneConfig::default()
1028            },
1029        );
1030        if pass.chars_reclaimed > 0 {
1031            *assembled = pass.messages;
1032            changed = true;
1033        }
1034        if estimate_tokens(assembled, est) <= budget {
1035            break;
1036        }
1037    }
1038    changed
1039}
1040
1041// ---------------------------------------------------------------------------
1042// Summary request + assembly
1043// ---------------------------------------------------------------------------
1044
1045/// The static fallback marker body — the only surviving form of the old
1046/// placeholder-discard.
1047fn static_fallback_text(removed: usize) -> String {
1048    format!("Summary generation was unavailable. {removed} message(s) were removed.")
1049}
1050
1051/// Wrap a summary body in the compaction markers as a `user` message.
1052/// The shape of the conversation middle being compressed (A4, #661). Drives the
1053/// summary section template: a tool-using (coding) middle gets file/action-centric
1054/// sections; a tool-free (Q&A / discussion) middle gets prose sections.
1055#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1056enum ConvShape {
1057    /// The middle contains tool calls — file edits, command runs, etc.
1058    Coding,
1059    /// No tool calls — plain question-answering / discussion / research.
1060    General,
1061}
1062
1063/// Classify the middle by a signal already on the wire: the presence of
1064/// `tool_calls`. A middle that issued tools is coding work; one that is pure
1065/// assistant/user prose is a Q&A/discussion. Coding is the conservative bias —
1066/// the only cost of a misclassification is a slightly-off (still valid) section
1067/// template, never a crash, and the load-bearing `## Active Task` /
1068/// `## Critical Context` slots exist in both shapes.
1069fn middle_shape(middle: &[Value]) -> ConvShape {
1070    let has_tools = middle
1071        .iter()
1072        .any(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()));
1073    if has_tools {
1074        ConvShape::Coding
1075    } else {
1076        ConvShape::General
1077    }
1078}
1079
1080/// #319: list the files read or edited in the summarized span, with a re-read
1081/// directive. The middle is replaced by a PROSE summary that does not preserve
1082/// verbatim signatures/types/lines; a coding model recalling an API from that
1083/// prose hallucinates it (the nemotron-3 incident). Naming the touched files
1084/// and instructing a re-read turns a confident hallucination into a re-read and
1085/// keeps the harness honest about what it dropped. Deterministic — independent
1086/// of whatever the summarizer LLM chose to mention.
1087fn reread_breadcrumb(middle: &[Value]) -> Option<String> {
1088    let mut paths: Vec<String> = Vec::new();
1089    for m in middle {
1090        if m["role"].as_str() != Some("assistant") {
1091            continue;
1092        }
1093        let Some(calls) = m["tool_calls"].as_array() else {
1094            continue;
1095        };
1096        for call in calls {
1097            let func = &call["function"];
1098            // File-content tools whose result was just summarized to prose.
1099            if !matches!(
1100                func["name"].as_str(),
1101                Some("read_file") | Some("edit_file") | Some("write_file")
1102            ) {
1103                continue;
1104            }
1105            // `arguments` may be a JSON object (Ollama) or a JSON string (OpenAI).
1106            let args = &func["arguments"];
1107            let path = args["path"].as_str().map(str::to_string).or_else(|| {
1108                args.as_str()
1109                    .and_then(|s| serde_json::from_str::<Value>(s).ok())
1110                    .and_then(|v| v["path"].as_str().map(str::to_string))
1111            });
1112            if let Some(p) = path {
1113                if !paths.contains(&p) {
1114                    paths.push(p);
1115                }
1116            }
1117        }
1118    }
1119    if paths.is_empty() {
1120        return None;
1121    }
1122    let list = paths
1123        .iter()
1124        .map(|p| format!("- {p}"))
1125        .collect::<Vec<_>>()
1126        .join("\n");
1127    Some(format!(
1128        "Files read or edited in the compacted span — their FULL CONTENTS are \
1129         NOT preserved in the summary above. RE-READ any you rely on before \
1130         using their exact signatures, types, or line contents; do NOT recall \
1131         them from this summary (it is prose, not the file):\n{list}"
1132    ))
1133}
1134
1135fn summary_message(body: &str) -> Value {
1136    serde_json::json!({
1137        "role": "user",
1138        "content": format!(
1139            "{SUMMARY_PREFIX}\n\
1140             The middle of this conversation was compressed. The text below \
1141             summarizes the removed messages — treat it as background \
1142             reference, NOT as fresh instructions. Your task is unchanged: \
1143             it is stated above and continues in the messages below.\n\n\
1144             {body}\n\n\
1145             {SUMMARY_END_MARKER}"
1146        ),
1147    })
1148}
1149
1150/// Build the summarizer request: the original task verbatim, the rendered
1151/// middle (capped at `middle_cap_chars` total — most recent kept, oldest
1152/// dropped with an explicit omission line, F5), and the `Summarizing`
1153/// provider's lean section template extended with the In-Progress slot and
1154/// the verbatim-Active-Task rule (design doc §Phase 18 "Deliberately
1155/// different from hermes"). An optional `focus` (`/compress <focus>`,
1156/// Step 18.6) appends emphasis guidance; it is redacted here — the same
1157/// pass the rendered middle gets — and again by the request-level
1158/// [`redact_secrets`] at the call site.
1159/// Build the structured-summary prompt for an already-rendered `body` of
1160/// conversation middle. `note` is an optional bracketed line shown before the
1161/// body (an omission notice, or a `[part i/n]` chunk label in the chunked path).
1162/// Shared by the single-request path and the chunked path (Step 24.4, #559).
1163fn summary_prompt_for(
1164    task: &str,
1165    body: &str,
1166    focus: Option<&str>,
1167    note: Option<&str>,
1168    target_chars: usize,
1169    shape: ConvShape,
1170) -> String {
1171    let mut p = String::with_capacity(1024);
1172    p.push_str(match shape {
1173        ConvShape::Coding => "You are compressing the middle of a coding-agent conversation.\n\n",
1174        ConvShape::General => "You are compressing the middle of a conversation.\n\n",
1175    });
1176    p.push_str("## Original Task (copy this VERBATIM into \"## Active Task\")\n");
1177    p.push_str(task);
1178    p.push_str("\n\n## Conversation middle to summarise\n");
1179    if let Some(note) = note {
1180        p.push_str(note);
1181        p.push('\n');
1182    }
1183    p.push_str(body);
1184    // A1 (#661): give the model an explicit, budget-derived LENGTH target so a
1185    // verbose summary can't reclaim <10% — chars→words ≈ /6, chars→tokens ≈ /4.
1186    let words = (target_chars / 6).max(40);
1187    let tokens = (target_chars / 4).max(60);
1188    // A4 (#661): shape-adaptive sections — a coding middle gets file/action-centric
1189    // slots; a Q&A/discussion middle gets prose slots, so the model doesn't pad
1190    // empty "Relevant Files"/"Completed Actions" (the off-task low-reclaim case).
1191    let sections = match shape {
1192        ConvShape::Coding => {
1193            "## Active Task\n## Completed Actions\n## In Progress\n## Key Decisions\n\
1194             ## Relevant Files\n## Critical Context\n"
1195        }
1196        ConvShape::General => {
1197            "## Active Task\n## Discussion\n## Key Points\n## Open Questions\n\
1198             ## Critical Context\n"
1199        }
1200    };
1201    p.push_str(&format!(
1202        "\nProduce a concise structured summary with sections:\n{sections}\
1203         Start \"## Active Task\" with the original task copied verbatim. \
1204         Keep the WHOLE summary under ~{words} words (~{tokens} tokens); if it \
1205         cannot all fit, drop low-salience detail — NEVER the Active Task. \
1206         Preserve specifics (file names, error messages, decisions). \
1207         NEVER include API keys, tokens, passwords, or other credentials — \
1208         write [REDACTED] instead.",
1209    ));
1210    if let Some(focus) = focus {
1211        let focus = redact_secrets(focus);
1212        let focus = focus.trim();
1213        if !focus.is_empty() {
1214            p.push_str(&format!(
1215                "\nThe user asked for this compression and wants emphasis on \
1216                 a topic: emphasize anything about {focus} — give it the bulk \
1217                 of the summary's detail while keeping every section above."
1218            ));
1219        }
1220    }
1221    p
1222}
1223
1224fn summary_request(
1225    task: &str,
1226    middle: &[Value],
1227    middle_cap_chars: usize,
1228    focus: Option<&str>,
1229    shape: ConvShape,
1230) -> String {
1231    // Keep the most recent suffix of the middle that fits the cap: the
1232    // recent middle is closest to the active work, and the verbatim task is
1233    // injected separately so nothing load-bearing rides on the oldest part.
1234    let rendered: Vec<String> = middle.iter().map(render_message).collect();
1235    let mut start = rendered.len();
1236    let mut total = 0usize;
1237    while start > 0 {
1238        let len = rendered[start - 1].chars().count();
1239        if start < rendered.len() && total + len > middle_cap_chars {
1240            break;
1241        }
1242        total += len;
1243        start -= 1;
1244    }
1245    let note = (start > 0).then(|| {
1246        format!(
1247            "[{start} older message(s) omitted from this summary input to fit \
1248             the summarizer's window]"
1249        )
1250    });
1251    let body: String = rendered[start..].concat();
1252    summary_prompt_for(
1253        task,
1254        &body,
1255        focus,
1256        note.as_deref(),
1257        middle_cap_chars / 3,
1258        shape,
1259    )
1260}
1261
1262/// Summarize the conversation `middle` within a per-request char cap, chunking
1263/// hierarchically when it doesn't fit one request (Step 24.4, #559).
1264///
1265/// A middle that fits the cap is one request — the established path. A larger
1266/// middle is split into ≤cap chunks, each summarized in its own bounded request
1267/// (sequentially — a flaky/OOM-prone box never sees the whole middle at once;
1268/// and a single failed chunk just drops, the others still land), then the chunk
1269/// summaries are reduced into one. So every request stays bounded AND no middle
1270/// message is silently dropped (the old single-request path omitted the oldest).
1271async fn summarize_middle(
1272    summarizer: &SummarizeFn,
1273    task: &str,
1274    middle: &[Value],
1275    cap_chars: usize,
1276    focus: Option<&str>,
1277) -> Option<String> {
1278    // A4 (#661): classify the whole middle once; every chunk + the reduce share it.
1279    let shape = middle_shape(middle);
1280    let rendered: Vec<String> = middle.iter().map(render_message).collect();
1281    let total: usize = rendered.iter().map(|r| r.chars().count()).sum();
1282    if total <= cap_chars {
1283        // Fits one request — the established single-call path (suffix-fit is a
1284        // no-op here since the whole middle fits).
1285        let req = redact_secrets(&summary_request(task, middle, cap_chars, focus, shape));
1286        return run_summary(summarizer, req).await;
1287    }
1288    let chunks = chunk_strings(&rendered, cap_chars);
1289    let n = chunks.len();
1290    let mut partials = Vec::with_capacity(n);
1291    for (i, chunk) in chunks.iter().enumerate() {
1292        let note = format!("[part {}/{} of the conversation middle]", i + 1, n);
1293        let req = redact_secrets(&summary_prompt_for(
1294            task,
1295            chunk,
1296            focus,
1297            Some(&note),
1298            cap_chars / 3,
1299            shape,
1300        ));
1301        if let Some(s) = run_summary(summarizer, req).await {
1302            partials.push(s);
1303        }
1304    }
1305    reduce_partials(summarizer, task, partials, cap_chars, focus, shape).await
1306}
1307
1308/// Group consecutive rendered strings into chunks each ≤ `cap` chars. A single
1309/// string longer than `cap` becomes its own over-cap chunk — `render_message`
1310/// already excerpts per-message content, so this stays bounded in practice.
1311fn chunk_strings(parts: &[String], cap: usize) -> Vec<String> {
1312    let mut chunks = Vec::new();
1313    let mut cur = String::new();
1314    let mut cur_len = 0usize;
1315    for p in parts {
1316        let len = p.chars().count();
1317        if cur_len > 0 && cur_len + len > cap {
1318            chunks.push(std::mem::take(&mut cur));
1319            cur_len = 0;
1320        }
1321        cur.push_str(p);
1322        cur_len += len;
1323    }
1324    if !cur.is_empty() {
1325        chunks.push(cur);
1326    }
1327    chunks
1328}
1329
1330/// Reduce chunk summaries into one (Step 24.4): a single consolidation pass when
1331/// they fit the cap, else re-chunk + reduce again — with a progress guard so a
1332/// non-converging input (each partial already ~cap) joins rather than looping.
1333fn reduce_partials<'a>(
1334    summarizer: &'a SummarizeFn,
1335    task: &'a str,
1336    partials: Vec<String>,
1337    cap_chars: usize,
1338    focus: Option<&'a str>,
1339    shape: ConvShape,
1340) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + 'a>> {
1341    Box::pin(async move {
1342        match partials.len() {
1343            0 => None,
1344            1 => partials.into_iter().next(),
1345            _ => {
1346                let joined_len: usize = partials.iter().map(|p| p.chars().count() + 2).sum();
1347                if joined_len <= cap_chars {
1348                    let body = partials.join("\n\n");
1349                    let note = format!(
1350                        "[{} partial summaries of ONE conversation — consolidate into one]",
1351                        partials.len()
1352                    );
1353                    let req = redact_secrets(&summary_prompt_for(
1354                        task,
1355                        &body,
1356                        focus,
1357                        Some(&note),
1358                        cap_chars / 3,
1359                        shape,
1360                    ));
1361                    return run_summary(summarizer, req).await;
1362                }
1363                let groups = chunk_strings(&partials, cap_chars);
1364                if groups.len() >= partials.len() {
1365                    // No progress possible — return what we have rather than loop.
1366                    return Some(partials.join("\n\n"));
1367                }
1368                let mut next = Vec::with_capacity(groups.len());
1369                for g in &groups {
1370                    let req = redact_secrets(&summary_prompt_for(
1371                        task,
1372                        g,
1373                        focus,
1374                        Some("[partial summaries — consolidate]"),
1375                        cap_chars / 3,
1376                        shape,
1377                    ));
1378                    if let Some(s) = run_summary(summarizer, req).await {
1379                        next.push(s);
1380                    }
1381                }
1382                reduce_partials(summarizer, task, next, cap_chars, focus, shape).await
1383            }
1384        }
1385    })
1386}
1387
1388/// Run one summary request: empty/whitespace output → `None`; error → logged and
1389/// `None` (degrades to the static marker, never aborts compression).
1390async fn run_summary(summarizer: &SummarizeFn, req: String) -> Option<String> {
1391    match summarizer(req).await {
1392        Ok(s) if !s.trim().is_empty() => Some(s),
1393        Ok(_) => None,
1394        Err(e) => {
1395            tracing::warn!(error = %e, "compression summarizer failed — static marker fallback");
1396            None
1397        }
1398    }
1399}
1400
1401/// Render one wire-shape message as a line of summarizer input.
1402///
1403/// Redaction runs BEFORE excerpting (N4): truncating at the excerpt cap can
1404/// otherwise slice a credential into a fragment too short for any redaction
1405/// pattern to match — the request-level `redact_secrets` pass would then
1406/// let it through. (That request-level pass still runs as a second layer.)
1407fn render_message(m: &Value) -> String {
1408    let role = m["role"].as_str().unwrap_or("unknown");
1409    let mut line = format!("[{role}]");
1410    if let Some(tcs) = m["tool_calls"].as_array() {
1411        for tc in tcs {
1412            let name = tc["function"]["name"].as_str().unwrap_or("tool");
1413            let args = tc["function"]["arguments"].to_string();
1414            line.push_str(" called ");
1415            line.push_str(name);
1416            line.push('(');
1417            line.push_str(&excerpt(&redact_secrets(&args), 200));
1418            line.push(')');
1419        }
1420    }
1421    if let Some(content) = m["content"].as_str() {
1422        if !content.is_empty() {
1423            line.push(' ');
1424            line.push_str(&excerpt(&redact_secrets(content), SUMMARY_INPUT_MSG_CAP));
1425        }
1426    }
1427    line.push('\n');
1428    line
1429}
1430
1431/// First `max_chars` chars, newlines preserved, `…`-terminated if cut.
1432fn excerpt(s: &str, max_chars: usize) -> String {
1433    if s.chars().count() <= max_chars {
1434        s.to_string()
1435    } else {
1436        let head: String = s.chars().take(max_chars).collect();
1437        format!("{head}…")
1438    }
1439}
1440
1441// ---------------------------------------------------------------------------
1442// Secret redaction
1443// ---------------------------------------------------------------------------
1444
1445/// `(pattern, replacement)` table for [`redact_secrets`]. Deliberately small
1446/// and high-precision: each row matches a *credential value shape*, not
1447/// prose about credentials — "the api key is in the keychain" must pass.
1448const REDACTION_TABLE: &[(&str, &str)] = &[
1449    // Private key blocks (redact even when the END line was truncated away).
1450    (
1451        r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\z)",
1452        "[REDACTED]",
1453    ),
1454    // OpenAI-style secret keys.
1455    (r"\bsk-[A-Za-z0-9_-]{20,}", "[REDACTED]"),
1456    // GitHub tokens (classic + fine-grained).
1457    (r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}", "[REDACTED]"),
1458    (r"\bgithub_pat_[A-Za-z0-9_]{20,}", "[REDACTED]"),
1459    // AWS access key ids.
1460    (r"\bAKIA[0-9A-Z]{16}\b", "[REDACTED]"),
1461    // JWTs (`eyJ` = base64 of `{"`).
1462    (
1463        r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}",
1464        "[REDACTED]",
1465    ),
1466    // HTTP bearer credentials. The value class has no spaces, so prose like
1467    // "bearer of good news" never reaches the 20-char floor.
1468    (
1469        r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{20,}",
1470        "Bearer [REDACTED]",
1471    ),
1472    // Generic credential assignment: a secret-ish key, `=`/`:`, and a
1473    // value of 8+ non-space chars. The key list is closed (no bare
1474    // "token"/"key") so token-budget talk passes. The optional quote after
1475    // the key matches the JSON-quoted shape (`"api_key": "…"`) — the native
1476    // form tool-call args take in this pipeline's summarizer input (F6).
1477    (
1478        r#"(?i)\b(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|password|passwd)\b["']?\s*[:=]\s*["']?[^\s"']{8,}["']?"#,
1479        "${1}=[REDACTED]",
1480    ),
1481];
1482
1483fn redaction_patterns() -> &'static Vec<(regex::Regex, &'static str)> {
1484    static PATTERNS: OnceLock<Vec<(regex::Regex, &'static str)>> = OnceLock::new();
1485    PATTERNS.get_or_init(|| {
1486        REDACTION_TABLE
1487            .iter()
1488            .map(|(pat, rep)| {
1489                (
1490                    regex::Regex::new(pat).expect("redaction pattern must compile"),
1491                    *rep,
1492                )
1493            })
1494            .collect()
1495    })
1496}
1497
1498/// Replace credential-looking strings with `[REDACTED]`. Applied to ALL
1499/// summarizer input — the summarizer LLM may ignore prompt instructions and
1500/// echo secrets back verbatim, and summaries persist for the conversation.
1501pub(crate) fn redact_secrets(input: &str) -> String {
1502    let mut out = input.to_string();
1503    for (re, rep) in redaction_patterns() {
1504        if re.is_match(&out) {
1505            out = re.replace_all(&out, *rep).into_owned();
1506        }
1507    }
1508    out
1509}
1510
1511// ---------------------------------------------------------------------------
1512// Tests
1513// ---------------------------------------------------------------------------
1514
1515#[cfg(test)]
1516mod tests {
1517    use super::*;
1518
1519    /// Default estimation (chars_per_token = 4) for the unit tests.
1520    const EST: TokenEstimation = TokenEstimation { chars_per_token: 4 };
1521    use serde_json::json;
1522    use std::sync::atomic::{AtomicUsize, Ordering};
1523    use std::sync::{Arc, Mutex};
1524
1525    // -- builders ------------------------------------------------------------
1526
1527    fn sys(text: &str) -> Value {
1528        json!({"role": "system", "content": text})
1529    }
1530
1531    fn user(text: &str) -> Value {
1532        json!({"role": "user", "content": text})
1533    }
1534
1535    fn assistant_call(name: &str, args: Value) -> Value {
1536        json!({"role": "assistant", "content": "",
1537               "tool_calls": [{"function": {"name": name, "arguments": args}}]})
1538    }
1539
1540    fn tool_result(content: &str) -> Value {
1541        json!({"role": "tool", "content": content})
1542    }
1543
1544    /// `[system, task, (assistant_call read_file → big result) × rounds]`.
1545    fn tool_heavy(task: &str, rounds: usize, result_chars: usize) -> Vec<Value> {
1546        let mut msgs = vec![sys("you are newt"), user(task)];
1547        for i in 0..rounds {
1548            msgs.push(assistant_call(
1549                "read_file",
1550                json!({"path": format!("src/file_{i}.rs")}),
1551            ));
1552            msgs.push(tool_result(&format!("{i}:{}", "x".repeat(result_chars))));
1553        }
1554        msgs
1555    }
1556
1557    /// A summarizer that records every prompt it receives and returns a
1558    /// canned summary.
1559    fn recording_summarizer(prompts: Arc<Mutex<Vec<String>>>, reply: &'static str) -> Summarizer {
1560        Box::new(move |prompt: String| {
1561            let prompts = prompts.clone();
1562            Box::pin(async move {
1563                prompts.lock().unwrap().push(prompt);
1564                Ok(reply.to_string())
1565            })
1566        })
1567    }
1568
1569    fn failing_summarizer(calls: Arc<AtomicUsize>) -> Summarizer {
1570        Box::new(move |_prompt: String| {
1571            let calls = calls.clone();
1572            Box::pin(async move {
1573                calls.fetch_add(1, Ordering::SeqCst);
1574                anyhow::bail!("summarizer endpoint 500")
1575            })
1576        })
1577    }
1578
1579    /// Hard-budget invocation (token threshold / send-budget semantics).
1580    /// Authoritative: the disabled-and-over case refuses (B6).
1581    async fn run(
1582        messages: &[Value],
1583        budget: usize,
1584        max_messages: Option<usize>,
1585        summarizer: Option<&SummarizeFn>,
1586        state: &mut CompressState,
1587    ) -> CompressOutcome {
1588        compress(
1589            CompressRequest {
1590                messages,
1591                budget,
1592                max_messages,
1593                task: "fix the failing test",
1594                hard_budget: true,
1595                authoritative: true,
1596                focus: None,
1597                est: EST,
1598                summary_input_cap_floor_chars: 8_192,
1599                compaction_store: None,
1600            },
1601            summarizer,
1602            state,
1603        )
1604        .await
1605    }
1606
1607    /// Hard-budget invocation on a NON-authoritative budget (Step 20.3): the
1608    /// proven-good HWM alone, no believed ceiling. The disabled-and-over case
1609    /// fails open (`DispatchedOverBudget`) instead of refusing.
1610    async fn run_non_authoritative(
1611        messages: &[Value],
1612        budget: usize,
1613        max_messages: Option<usize>,
1614        summarizer: Option<&SummarizeFn>,
1615        state: &mut CompressState,
1616    ) -> CompressOutcome {
1617        compress(
1618            CompressRequest {
1619                messages,
1620                budget,
1621                max_messages,
1622                task: "fix the failing test",
1623                hard_budget: true,
1624                authoritative: false,
1625                focus: None,
1626                est: EST,
1627                summary_input_cap_floor_chars: 8_192,
1628                compaction_store: None,
1629            },
1630            summarizer,
1631            state,
1632        )
1633        .await
1634    }
1635
1636    /// Count-only (VRAM guard) invocation: soft aim-to-halve budget that
1637    /// neither consults nor feeds anti-thrash (F2).
1638    async fn run_count_only(
1639        messages: &[Value],
1640        budget: usize,
1641        max_messages: Option<usize>,
1642        summarizer: Option<&SummarizeFn>,
1643        state: &mut CompressState,
1644    ) -> CompressOutcome {
1645        compress(
1646            CompressRequest {
1647                messages,
1648                budget,
1649                max_messages,
1650                task: "fix the failing test",
1651                hard_budget: false,
1652                authoritative: false,
1653                focus: None,
1654                est: EST,
1655                summary_input_cap_floor_chars: 8_192,
1656                compaction_store: None,
1657            },
1658            summarizer,
1659            state,
1660        )
1661        .await
1662    }
1663
1664    // -- pipeline order -------------------------------------------------------
1665
1666    /// Under budget → untouched, no anti-thrash accounting.
1667    #[tokio::test]
1668    async fn within_budget_is_a_noop() {
1669        let msgs = tool_heavy("task", 2, 100);
1670        let mut state = CompressState::new();
1671        let out = run(&msgs, 100_000, None, None, &mut state).await;
1672        assert_eq!(out.action, CompressAction::Fit);
1673        assert!(!out.fired);
1674        assert_eq!(out.messages, msgs);
1675        assert_eq!(state.attempts, 0, "a no-op never counts as a compression");
1676    }
1677
1678    /// Prune-first short-circuit: when the structural passes reclaim enough,
1679    /// the summarizer is never invoked (zero LLM cost).
1680    #[tokio::test]
1681    async fn prune_short_circuits_when_sufficient() {
1682        // 14 messages: 2 aged huge identical results (dedupe + one-liner
1683        // fodder) + 10 protected-tail fillers.
1684        let big = "y".repeat(8_000);
1685        let mut msgs = vec![
1686            sys("you are newt"),
1687            user("task"),
1688            assistant_call("run_command", json!({"command": "cargo test"})),
1689            tool_result(&big),
1690            assistant_call("run_command", json!({"command": "cargo test"})),
1691            tool_result(&big),
1692        ];
1693        for i in 0..10 {
1694            msgs.push(user(&format!("filler {i}")));
1695        }
1696        let before = estimate_tokens(&msgs, EST);
1697        let budget = before - 1_000; // prune reclaims ~4k tokens — plenty
1698        let prompts = Arc::new(Mutex::new(Vec::new()));
1699        let s = recording_summarizer(prompts.clone(), "SUMMARY");
1700        let mut state = CompressState::new();
1701        let out = run(&msgs, budget, None, Some(&*s), &mut state).await;
1702        assert_eq!(out.action, CompressAction::Pruned);
1703        assert!(out.fired);
1704        assert!(out.tokens_after <= budget);
1705        assert_eq!(out.messages.len(), msgs.len(), "prune never drops messages");
1706        assert!(
1707            prompts.lock().unwrap().is_empty(),
1708            "summarizer must not be called when pruning suffices"
1709        );
1710    }
1711
1712    /// Prune insufficient → the middle is summarized; head + tail survive
1713    /// verbatim, markers wrap the summary, the old placeholder is gone.
1714    #[tokio::test]
1715    async fn summarizes_middle_with_markers_when_prune_insufficient() {
1716        let msgs = tool_heavy("ACTIVE TASK GAUNTLET-7f3d9c: do the thing", 6, 4_000);
1717        let before = estimate_tokens(&msgs, EST);
1718        let prompts = Arc::new(Mutex::new(Vec::new()));
1719        let s = recording_summarizer(prompts.clone(), "## Active Task\nGAUNTLET summary");
1720        let mut state = CompressState::new();
1721        let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1722
1723        assert_eq!(out.action, CompressAction::Summarized);
1724        assert!(out.fired);
1725        assert!(out.tokens_after < before);
1726        // Head anchored verbatim.
1727        assert_eq!(out.messages[0], msgs[0]);
1728        assert_eq!(out.messages[1], msgs[1]);
1729        // The summary message carries both markers and the summary body.
1730        let summary = out.messages[2]["content"].as_str().unwrap();
1731        assert!(summary.starts_with(SUMMARY_PREFIX), "{summary}");
1732        assert!(summary.contains("GAUNTLET summary"), "{summary}");
1733        assert!(summary.contains(SUMMARY_END_MARKER), "{summary}");
1734        // The old amputation placeholder must be gone from this path.
1735        assert!(
1736            !out.messages.iter().any(|m| m["content"]
1737                .as_str()
1738                .is_some_and(|c| c.contains("earlier tool-call messages omitted"))),
1739            "the old placeholder-discard line must not appear"
1740        );
1741    }
1742
1743    /// #319 REGRESSION GUARD: an API surface read EARLY then needed LATER is
1744    /// summarized out of the middle (the freshest trailing group + ~budget/4
1745    /// token tail are protected; an older read is not). The summary is prose,
1746    /// so the verbatim signature is gone — but the fix appends a re-read
1747    /// breadcrumb naming the dropped file, so the model is told to RE-READ it
1748    /// rather than hallucinate. This guards that the breadcrumb names the file
1749    /// and carries the directive.
1750    #[tokio::test]
1751    async fn summarized_file_reads_get_a_reread_breadcrumb() {
1752        let sig = "pub fn connect(&self, url: &str, timeout: Duration) -> Result<Session, ConnErr>";
1753        let api_body = format!(
1754            "pub struct ApiClient;\nimpl ApiClient {{\n    {sig} {{ todo!() }}\n}}\n{}",
1755            "// detail line\n".repeat(200)
1756        );
1757        let mut msgs = vec![
1758            sys("you are newt, a coding agent"),
1759            user("ACTIVE TASK: implement reconnect() on ApiClient using its connect() method"),
1760            assistant_call("read_file", json!({ "path": "src/api.rs" })),
1761            tool_result(&api_body), // the API surface, read EARLY
1762        ];
1763        // ...then several more rounds of OTHER reads, pushing src/api.rs out of
1764        // both the freshest trailing group and the token-budgeted tail.
1765        for i in 0..8 {
1766            msgs.push(assistant_call(
1767                "read_file",
1768                json!({ "path": format!("src/other_{i}.rs") }),
1769            ));
1770            msgs.push(tool_result(&format!(
1771                "// other file {i}\n{}",
1772                "filler line\n".repeat(150)
1773            )));
1774        }
1775        let before = estimate_tokens(&msgs, EST);
1776        let prompts = Arc::new(Mutex::new(Vec::new()));
1777        // The real summarizer returns PROSE, never code — model that.
1778        let s = recording_summarizer(
1779            prompts.clone(),
1780            "## Active Task\nImplement reconnect(). The agent earlier read src/api.rs \
1781             (defines ApiClient) and several other files.",
1782        );
1783        let mut state = CompressState::new();
1784        let out = run(&msgs, before / 2, None, Some(&*s), &mut state).await;
1785
1786        let assembled: String = out
1787            .messages
1788            .iter()
1789            .filter_map(|m| m["content"].as_str())
1790            .collect::<Vec<_>>()
1791            .join("\n");
1792        eprintln!(
1793            "#319: fired={} action={:?}\n{}",
1794            out.fired,
1795            out.action,
1796            &assembled[..assembled.len().min(1200)]
1797        );
1798        // The summary fired (the early read did land in the compacted middle).
1799        assert!(out.fired && out.action == CompressAction::Summarized);
1800        // The fix: the model is TOLD the file is stale and must be re-read,
1801        // by name — not left to recall a fabricated signature from prose.
1802        assert!(
1803            assembled.contains("src/api.rs"),
1804            "the dropped file must be named so the model knows to re-read it"
1805        );
1806        assert!(
1807            assembled.contains("RE-READ") && assembled.contains("do NOT recall"),
1808            "the breadcrumb must carry the re-read / don't-recall directive"
1809        );
1810    }
1811
1812    /// The summary request contains the original task verbatim, the lean
1813    /// template sections, and the verbatim-Active-Task rule.
1814    #[tokio::test]
1815    async fn summary_request_carries_task_verbatim_and_template() {
1816        let task = "ACTIVE TASK GAUNTLET-7f3d9c: read ten files then report";
1817        let mut msgs = tool_heavy(task, 6, 4_000);
1818        msgs[1] = user(task);
1819        let before = estimate_tokens(&msgs, EST);
1820        let prompts = Arc::new(Mutex::new(Vec::new()));
1821        let s = recording_summarizer(prompts.clone(), "SUMMARY");
1822        let mut state = CompressState::new();
1823        let out = compress(
1824            CompressRequest {
1825                messages: &msgs,
1826                budget: before / 3,
1827                max_messages: None,
1828                task,
1829                hard_budget: true,
1830                authoritative: true,
1831                focus: None,
1832                est: EST,
1833                summary_input_cap_floor_chars: 8_192,
1834                compaction_store: None,
1835            },
1836            Some(&*s),
1837            &mut state,
1838        )
1839        .await;
1840        assert_eq!(out.action, CompressAction::Summarized);
1841
1842        let prompts = prompts.lock().unwrap();
1843        assert_eq!(prompts.len(), 1);
1844        let p = &prompts[0];
1845        assert!(p.contains(task), "original task must appear verbatim: {p}");
1846        for section in [
1847            "## Active Task",
1848            "## Completed Actions",
1849            "## In Progress",
1850            "## Key Decisions",
1851            "## Relevant Files",
1852            "## Critical Context",
1853        ] {
1854            assert!(p.contains(section), "missing template section {section}");
1855        }
1856        assert!(p.contains("copied verbatim"), "verbatim-Active-Task rule");
1857        assert!(p.contains("[REDACTED]"), "redaction preamble present");
1858    }
1859
1860    /// No summarizer → static fallback marker with the exact removed count.
1861    #[tokio::test]
1862    async fn no_summarizer_uses_static_fallback_marker() {
1863        let msgs = tool_heavy("task", 6, 4_000);
1864        let before = estimate_tokens(&msgs, EST);
1865        let mut state = CompressState::new();
1866        let out = run(&msgs, before / 3, None, None, &mut state).await;
1867        assert_eq!(out.action, CompressAction::StaticFallback);
1868        let summary = out.messages[2]["content"].as_str().unwrap();
1869        assert!(summary.starts_with(SUMMARY_PREFIX), "{summary}");
1870        assert!(summary.contains(SUMMARY_END_MARKER), "{summary}");
1871        // middle = messages [2, tail_start): compute the expected count from
1872        // the output shape (head 2 + marker 1 + tail).
1873        let removed = msgs.len() - (out.messages.len() - 1);
1874        assert!(
1875            summary.contains(&format!(
1876                "Summary generation was unavailable. {removed} message(s) were removed."
1877            )),
1878            "{summary}"
1879        );
1880    }
1881
1882    /// Summarizer failure → static marker; the pipeline never errors out.
1883    #[tokio::test]
1884    async fn summarizer_failure_falls_back_to_static_marker() {
1885        let msgs = tool_heavy("task", 6, 4_000);
1886        let before = estimate_tokens(&msgs, EST);
1887        let calls = Arc::new(AtomicUsize::new(0));
1888        let s = failing_summarizer(calls.clone());
1889        let mut state = CompressState::new();
1890        let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1891        assert_eq!(calls.load(Ordering::SeqCst), 1, "summarizer was attempted");
1892        assert_eq!(out.action, CompressAction::StaticFallback);
1893        let summary = out.messages[2]["content"].as_str().unwrap();
1894        assert!(summary.contains("Summary generation was unavailable."));
1895    }
1896
1897    /// An empty/whitespace summary counts as a failure (static marker).
1898    #[tokio::test]
1899    async fn empty_summary_falls_back_to_static_marker() {
1900        let msgs = tool_heavy("task", 6, 4_000);
1901        let before = estimate_tokens(&msgs, EST);
1902        let prompts = Arc::new(Mutex::new(Vec::new()));
1903        let s = recording_summarizer(prompts.clone(), "  \n ");
1904        let mut state = CompressState::new();
1905        let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1906        assert_eq!(out.action, CompressAction::StaticFallback);
1907    }
1908
1909    /// The B6 shape with an AGED giant round: one giant tool round that no
1910    /// boundary can split, followed by a newer small round — the final fit
1911    /// pass one-lines the giant (aged) results under budget instead of
1912    /// letting the backend silently truncate the head.
1913    #[tokio::test]
1914    async fn giant_aged_round_is_pruned_aggressively_not_shipped_over_budget() {
1915        let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
1916        let mut msgs = vec![sys("you are newt"), user(task)];
1917        msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
1918            {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
1919            {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
1920            {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
1921        ]}));
1922        for _ in 0..3 {
1923            msgs.push(tool_result(&"z".repeat(50_000))); // ~12.5k tokens each
1924        }
1925        // The newer (fresh) round the model has not seen yet.
1926        msgs.push(assistant_call("read_file", json!({"path": "d.txt"})));
1927        msgs.push(tool_result("short fresh result"));
1928        let mut state = CompressState::new();
1929        let out = run(&msgs, 3_000, None, None, &mut state).await;
1930        assert!(
1931            out.tokens_after <= 3_000,
1932            "the fit pass must bring ~{} under budget, got {}",
1933            out.tokens_before,
1934            out.tokens_after
1935        );
1936        assert!(out.fired);
1937        // The task survives verbatim — the property B6 measured the loss of.
1938        assert!(out
1939            .messages
1940            .iter()
1941            .any(|m| m["content"].as_str() == Some(task)));
1942        // Pairing intact: 3 + 1 calls, 4 results (giants one-lined).
1943        assert_eq!(out.messages[2]["tool_calls"].as_array().unwrap().len(), 3);
1944        assert_eq!(
1945            out.messages
1946                .iter()
1947                .filter(|m| m["role"].as_str() == Some("tool"))
1948                .count(),
1949            4
1950        );
1951        // The fresh trailing result is untouched.
1952        assert_eq!(
1953            out.messages.last().unwrap()["content"].as_str(),
1954            Some("short fresh result")
1955        );
1956    }
1957
1958    /// F1c: under SOFT (count-only / `/compress`) pressure the trailing tool
1959    /// group — the fresh results the model has not seen yet — is NEVER
1960    /// pruned, even when protecting it means the assembled list misses the
1961    /// aim-to-halve target. (The old `keep_last: 0` fit pass one-lined the
1962    /// freshest results pre-dispatch from the second compression of a
1963    /// session on — the model could never read anything.) The HARD-budget
1964    /// variant of this exact shape is #285's within-group reclaim, pinned by
1965    /// `oversized_group_reclaims_within_keeping_newest_whole` below.
1966    #[tokio::test]
1967    async fn fresh_trailing_tool_group_survives_the_aggressive_pass() {
1968        let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
1969        let big = "z".repeat(50_000);
1970        let mut msgs = vec![sys("you are newt"), user(task)];
1971        msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
1972            {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
1973            {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
1974            {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
1975        ]}));
1976        for _ in 0..3 {
1977            msgs.push(tool_result(&big));
1978        }
1979        let mut state = CompressState::new();
1980        let out = run_count_only(&msgs, 3_000, None, None, &mut state).await;
1981        // All three fresh results reach the model byte-identical; the
1982        // over-target result is the accepted trade for a soft budget (a
1983        // missed aim-to-halve is not a correctness problem).
1984        let results: Vec<&str> = out
1985            .messages
1986            .iter()
1987            .filter(|m| m["role"].as_str() == Some("tool"))
1988            .map(|m| m["content"].as_str().unwrap())
1989            .collect();
1990        assert_eq!(results.len(), 3);
1991        for r in results {
1992            assert_eq!(r, big, "fresh trailing tool results must never be pruned");
1993        }
1994        assert!(
1995            out.tokens_after > 3_000,
1996            "this shape is genuinely incompressible without destroying fresh results"
1997        );
1998    }
1999
2000    // -- trailing-group protection (#270 / #285) -------------------------------
2001
2002    /// #270's root cause, pinned at the derivation: the protected suffix is
2003    /// anchored on the last assistant-with-`tool_calls`, so an interleaved
2004    /// user message (the read-only nudge) or a trailing compaction notice
2005    /// can never truncate it. The old `take_while(role == "tool")` from the
2006    /// end read 0 in both interleaved shapes.
2007    #[test]
2008    fn trailing_group_derivation_survives_interleaved_messages() {
2009        let mut msgs = vec![sys("you are newt"), user("task")];
2010        msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2011            {"function": {"name": "read_file", "arguments": {"path": "a.rs"}}},
2012            {"function": {"name": "read_file", "arguments": {"path": "b.rs"}}},
2013        ]}));
2014        msgs.push(tool_result("result a"));
2015        msgs.push(tool_result("result b"));
2016        // Normal case: assistant turn + its two results.
2017        assert_eq!(trailing_tool_group_len(&msgs), 3);
2018        // The #270 repro: the read-only nudge lands AFTER the fresh results,
2019        // immediately before the compression call site.
2020        msgs.push(user(
2021            "[3 consecutive read-only rounds with no file writes.]",
2022        ));
2023        assert_eq!(trailing_tool_group_len(&msgs), 4);
2024        // A trailing compaction notice doesn't truncate the group either.
2025        msgs.push(summary_message("reference summary"));
2026        assert_eq!(trailing_tool_group_len(&msgs), 5);
2027        // A plain assistant reply (no tool_calls) does not re-anchor.
2028        msgs.push(json!({"role": "assistant", "content": "thinking…"}));
2029        assert_eq!(trailing_tool_group_len(&msgs), 6);
2030        // No assistant ever called a tool → no group.
2031        assert_eq!(trailing_tool_group_len(&[sys("s"), user("t")]), 0);
2032        // The loop appends the backend's `message` verbatim and some
2033        // dialects omit `role` on it — `tool_calls` alone anchors the group.
2034        let roleless = vec![
2035            user("task"),
2036            json!({"content": "", "tool_calls": [
2037                {"function": {"name": "read_file", "arguments": {"path": "a"}}}]}),
2038            tool_result("result a"),
2039        ];
2040        assert_eq!(trailing_tool_group_len(&roleless), 2);
2041    }
2042
2043    /// The #270 repro through the whole pipeline: an over-budget session
2044    /// whose fresh trailing group (two unseen results) is followed by the
2045    /// read-only nudge's user message. Pre-fix the aggressive pass saw zero
2046    /// trailing tools, floored `keep_last` at 2 ([UNSEEN2, nudge]), and
2047    /// one-lined UNSEEN1 pre-dispatch — the probe measured 7,213 → 2,207
2048    /// tokens with UNSEEN1 (8 KB) destroyed. Post-fix the whole group
2049    /// survives byte-identical.
2050    #[tokio::test]
2051    async fn nudge_after_fresh_group_does_not_defeat_the_protection() {
2052        let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2053        let unseen1 = format!("1:{}", "u".repeat(8_000));
2054        let unseen2 = format!("2:{}", "v".repeat(8_000));
2055        let mut msgs = vec![sys("you are newt"), user(task)];
2056        // Aged mass for the earlier passes to reclaim.
2057        for i in 0..6 {
2058            msgs.push(assistant_call(
2059                "read_file",
2060                json!({"path": format!("aged_{i}.rs")}),
2061            ));
2062            msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2063        }
2064        // The fresh group: one assistant turn, two unseen results…
2065        msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2066            {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2067            {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2068        ]}));
2069        msgs.push(tool_result(&unseen1));
2070        msgs.push(tool_result(&unseen2));
2071        // …then the read-only nudge, exactly where the loop injects it.
2072        msgs.push(user(
2073            "[3 consecutive read-only rounds with no file writes. \
2074             Stop exploring. Call edit_file or write_file now.]",
2075        ));
2076        let mut state = CompressState::new();
2077        // Soft (count-only) pressure: the F1c protection is absolute here —
2078        // the assembled list stays over the aim-to-halve target rather than
2079        // destroy an unseen result.
2080        let out = run_count_only(&msgs, 2_000, None, None, &mut state).await;
2081        assert!(out.fired);
2082        let tool_contents: Vec<&str> = out
2083            .messages
2084            .iter()
2085            .filter(|m| m["role"].as_str() == Some("tool"))
2086            .map(|m| m["content"].as_str().unwrap())
2087            .collect();
2088        assert!(
2089            tool_contents.contains(&unseen1.as_str()),
2090            "#270: UNSEEN1 must survive the nudge-truncated derivation \
2091             (got tool contents {:?})",
2092            tool_contents
2093                .iter()
2094                .map(|c| c.chars().take(40).collect::<String>())
2095                .collect::<Vec<_>>()
2096        );
2097        assert!(
2098            tool_contents.contains(&unseen2.as_str()),
2099            "UNSEEN2 must survive too"
2100        );
2101        // The nudge itself still reaches the model (nothing silently drops).
2102        assert!(out.messages.iter().any(|m| m["content"]
2103            .as_str()
2104            .is_some_and(|c| c.contains("read-only rounds"))));
2105        println!(
2106            "#270 repro trace: {} -> {} est. tokens (target {}), group intact",
2107            out.tokens_before, out.tokens_after, 2_000
2108        );
2109    }
2110
2111    /// Same shape with a trailing compaction notice instead of the nudge —
2112    /// the other interleaved-message family `is_compaction_message` covers.
2113    #[tokio::test]
2114    async fn compaction_notice_after_fresh_group_does_not_defeat_the_protection() {
2115        let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2116        let unseen1 = format!("1:{}", "u".repeat(8_000));
2117        let unseen2 = format!("2:{}", "v".repeat(8_000));
2118        let mut msgs = vec![sys("you are newt"), user(task)];
2119        for i in 0..6 {
2120            msgs.push(assistant_call(
2121                "read_file",
2122                json!({"path": format!("aged_{i}.rs")}),
2123            ));
2124            msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2125        }
2126        msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2127            {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2128            {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2129        ]}));
2130        msgs.push(tool_result(&unseen1));
2131        msgs.push(tool_result(&unseen2));
2132        msgs.push(summary_message("## Active Task\nreference summary"));
2133        let mut state = CompressState::new();
2134        let out = run_count_only(&msgs, 2_000, None, None, &mut state).await;
2135        let tool_contents: Vec<&str> = out
2136            .messages
2137            .iter()
2138            .filter(|m| m["role"].as_str() == Some("tool"))
2139            .map(|m| m["content"].as_str().unwrap())
2140            .collect();
2141        assert!(tool_contents.contains(&unseen1.as_str()), "UNSEEN1 intact");
2142        assert!(tool_contents.contains(&unseen2.as_str()), "UNSEEN2 intact");
2143    }
2144
2145    /// #285 mechanism, pinned at the helper: within-group reclaim fires ONLY
2146    /// when the group by itself exceeds the budget left after everything
2147    /// before it; one-lines oldest-first; stops as soon as the list fits;
2148    /// the newest member is never a candidate.
2149    #[test]
2150    fn within_group_reclaim_fires_only_when_group_alone_exceeds() {
2151        let big = "z".repeat(20_000); // ~5k tokens
2152        let small = "s".repeat(1_200); // ~300 tokens
2153        let group = |contents: &[&str]| -> Vec<Value> {
2154            let mut msgs = vec![sys("you are newt"), user("task")];
2155            msgs.push(json!({"role": "assistant", "content": "", "tool_calls":
2156                contents.iter().enumerate().map(|(i, _)| json!(
2157                    {"function": {"name": "read_file",
2158                                  "arguments": {"path": format!("f{i}.txt")}}}
2159                )).collect::<Vec<_>>()
2160            }));
2161            msgs.extend(contents.iter().map(|c| tool_result(c)));
2162            msgs
2163        };
2164
2165        // Under-budget group: untouched, returns false (the F1c property).
2166        let mut fits = group(&[&small, &small, &small]);
2167        let before = fits.clone();
2168        assert!(!reclaim_within_trailing_group(&mut fits, 10_000, EST));
2169        assert_eq!(fits, before, "a group within its share is never touched");
2170
2171        // No group at all: no-op.
2172        let mut no_group = vec![sys("s"), user(&big)];
2173        assert!(!reclaim_within_trailing_group(&mut no_group, 100, EST));
2174
2175        // Single-member group over budget: the newest IS the only member —
2176        // untouched, truthful over-budget residual (clipping inside one
2177        // result is out of scope).
2178        let mut single = group(&[&big]);
2179        let before = single.clone();
2180        assert!(!reclaim_within_trailing_group(&mut single, 1_000, EST));
2181        assert_eq!(single, before);
2182
2183        // Oversized group, early stop: one-lining the OLDEST member alone
2184        // fits the budget — the middle and newest members stay whole.
2185        let mut early = group(&[&big, &small, &small]);
2186        assert!(reclaim_within_trailing_group(&mut early, 1_500, EST));
2187        let results: Vec<&str> = early
2188            .iter()
2189            .filter(|m| m["role"].as_str() == Some("tool"))
2190            .map(|m| m["content"].as_str().unwrap())
2191            .collect();
2192        assert!(
2193            results[0].starts_with("[read_file] read 'f0.txt'"),
2194            "oldest one-lined with the re-read affordance: {}",
2195            results[0]
2196        );
2197        assert_eq!(results[1], small, "middle untouched after early stop");
2198        assert_eq!(results[2], small, "newest untouched");
2199        assert!(estimate_tokens(&early, EST) <= 1_500, "the list now fits");
2200
2201        // Newest alone exceeds the budget: all older members one-lined, the
2202        // newest still whole, the list honestly stays over.
2203        let mut residual = group(&[&small, &small, &big]);
2204        assert!(reclaim_within_trailing_group(&mut residual, 1_000, EST));
2205        let results: Vec<&str> = residual
2206            .iter()
2207            .filter(|m| m["role"].as_str() == Some("tool"))
2208            .map(|m| m["content"].as_str().unwrap())
2209            .collect();
2210        assert!(results[0].starts_with("[read_file] read 'f0.txt'"));
2211        assert!(results[1].starts_with("[read_file] read 'f1.txt'"));
2212        assert_eq!(results[2], big, "the newest member is never a candidate");
2213        assert!(
2214            estimate_tokens(&residual, EST) > 1_000,
2215            "single-result-too-big: truthfully still over budget"
2216        );
2217    }
2218
2219    /// #285 through the whole pipeline (the B6 residual measured in #284's
2220    /// gauntlet): ONE round's tool group alone exceeds a HARD budget. The
2221    /// F1c protection yields within the group: a.txt / b.txt one-lined
2222    /// (each naming its file for re-read), c.txt — the newest — byte-
2223    /// identical. Here even c.txt alone exceeds the budget, so the outcome
2224    /// honestly stays over (the loop's notice reports real numbers) rather
2225    /// than clipping inside the result.
2226    #[tokio::test]
2227    async fn oversized_group_reclaims_within_keeping_newest_whole() {
2228        let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
2229        let big = "z".repeat(50_000); // ~12.5k tokens each
2230        let mut msgs = vec![sys("you are newt"), user(task)];
2231        msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2232            {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
2233            {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
2234            {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
2235        ]}));
2236        for _ in 0..3 {
2237            msgs.push(tool_result(&big));
2238        }
2239        let mut state = CompressState::new();
2240        let out = run(&msgs, 3_000, None, None, &mut state).await;
2241        assert!(out.fired);
2242        let results: Vec<&str> = out
2243            .messages
2244            .iter()
2245            .filter(|m| m["role"].as_str() == Some("tool"))
2246            .map(|m| m["content"].as_str().unwrap())
2247            .collect();
2248        assert_eq!(results.len(), 3, "pairing intact — nothing dropped");
2249        assert!(
2250            results[0].starts_with("[read_file] read 'a.txt'"),
2251            "oldest one-lined, file named for re-read: {}",
2252            results[0]
2253        );
2254        assert!(
2255            results[1].starts_with("[read_file] read 'b.txt'"),
2256            "older one-lined in order: {}",
2257            results[1]
2258        );
2259        assert_eq!(results[2], big, "newest result reaches the model whole");
2260        // The task survives verbatim (the property B6 measured the loss of).
2261        assert!(out
2262            .messages
2263            .iter()
2264            .any(|m| m["content"].as_str() == Some(task)));
2265        // Honesty: the newest alone is ~12.5k tokens against a 3k budget —
2266        // the outcome reports genuinely over, never a silent fit claim.
2267        assert!(out.tokens_after > 3_000);
2268        assert!(
2269            out.tokens_after < out.tokens_before / 2,
2270            "but the reclaim was real: {} -> {}",
2271            out.tokens_before,
2272            out.tokens_after
2273        );
2274        println!(
2275            "#285 scenario trace: {} -> {} est. tokens (budget 3000), \
2276             a/b one-lined, c whole",
2277            out.tokens_before, out.tokens_after
2278        );
2279    }
2280
2281    /// #285 boundary: when the group fits a HARD budget once everything
2282    /// outside it is reclaimed, within-group reclaim must NOT fire — the
2283    /// dispatch lands under budget with every fresh result intact.
2284    #[tokio::test]
2285    async fn under_budget_group_is_untouched_under_hard_pressure() {
2286        let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2287        let unseen1 = format!("1:{}", "u".repeat(8_000)); // ~2k tokens
2288        let unseen2 = format!("2:{}", "v".repeat(8_000));
2289        let mut msgs = vec![sys("you are newt"), user(task)];
2290        for i in 0..6 {
2291            msgs.push(assistant_call(
2292                "read_file",
2293                json!({"path": format!("aged_{i}.rs")}),
2294            ));
2295            msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2296        }
2297        msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2298            {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2299            {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2300        ]}));
2301        msgs.push(tool_result(&unseen1));
2302        msgs.push(tool_result(&unseen2));
2303        msgs.push(user(
2304            "[3 consecutive read-only rounds with no file writes.]",
2305        ));
2306        let mut state = CompressState::new();
2307        // 6,000-token hard budget: the ~4.2k-token group fits once the aged
2308        // middle is summarized away.
2309        let out = run(&msgs, 6_000, None, None, &mut state).await;
2310        assert!(out.fired);
2311        assert!(
2312            out.tokens_after <= 6_000,
2313            "must land under the hard budget ({} -> {})",
2314            out.tokens_before,
2315            out.tokens_after
2316        );
2317        let tool_contents: Vec<&str> = out
2318            .messages
2319            .iter()
2320            .filter(|m| m["role"].as_str() == Some("tool"))
2321            .map(|m| m["content"].as_str().unwrap())
2322            .collect();
2323        assert!(tool_contents.contains(&unseen1.as_str()), "UNSEEN1 whole");
2324        assert!(tool_contents.contains(&unseen2.as_str()), "UNSEEN2 whole");
2325    }
2326
2327    /// The count trigger (`max_messages`) forces the summary stage even when
2328    /// tokens already fit — pruning can never reduce the message count.
2329    #[tokio::test]
2330    async fn max_messages_forces_summary_stage() {
2331        let msgs = tool_heavy("task", 8, 50); // small payloads: tokens fit
2332        let before = estimate_tokens(&msgs, EST);
2333        let prompts = Arc::new(Mutex::new(Vec::new()));
2334        let s = recording_summarizer(prompts.clone(), "SUMMARY");
2335        let mut state = CompressState::new();
2336        let out = run_count_only(&msgs, before + 1_000, Some(8), Some(&*s), &mut state).await;
2337        assert_eq!(out.action, CompressAction::Summarized);
2338        assert!(out.messages.len() < msgs.len());
2339    }
2340
2341    /// F1 (the headline regression): a SECOND compression of an already-
2342    /// compressed conversation must still shrink it. The bug anchored the
2343    /// boundary on the first pass's own summary message, the middle went
2344    /// empty, the count never dropped, and the fit pass destroyed every
2345    /// fresh tool result pre-dispatch from then on.
2346    #[tokio::test]
2347    async fn second_compression_still_shrinks_and_keeps_fresh_results() {
2348        let fresh = format!("9:{}", "x".repeat(4_000));
2349        let msgs = tool_heavy("fix the failing test", 10, 4_000);
2350        let prompts = Arc::new(Mutex::new(Vec::new()));
2351        let s = recording_summarizer(prompts.clone(), "SUMMARY ONE");
2352        let mut state = CompressState::new();
2353        let budget = estimate_tokens(&msgs, EST) / 2;
2354        let first = run_count_only(&msgs, budget, Some(8), Some(&*s), &mut state).await;
2355        assert!(first.messages.len() < msgs.len(), "first pass shrinks");
2356        assert!(first.messages.iter().any(is_compaction_message));
2357
2358        // Six more rounds land on top of the compressed list.
2359        let mut grown = first.messages.clone();
2360        for i in 10..16 {
2361            grown.push(assistant_call(
2362                "read_file",
2363                json!({"path": format!("src/file_{i}.rs")}),
2364            ));
2365            grown.push(tool_result(&format!("{i}:{}", "x".repeat(4_000))));
2366        }
2367        let grown_fresh = grown.last().unwrap()["content"]
2368            .as_str()
2369            .unwrap()
2370            .to_string();
2371        let budget2 = estimate_tokens(&grown, EST) / 2;
2372        let second = run_count_only(&grown, budget2, Some(8), Some(&*s), &mut state).await;
2373        assert!(
2374            second.messages.len() < grown.len(),
2375            "second compression must still shrink ({} -> {})",
2376            grown.len(),
2377            second.messages.len()
2378        );
2379        assert!(
2380            second.messages.len() <= 10,
2381            "count goal must stay reachable, got {}",
2382            second.messages.len()
2383        );
2384        // The freshest tool result reaches the model intact, both passes.
2385        assert_eq!(
2386            first.messages.last().unwrap()["content"].as_str(),
2387            Some(fresh.as_str()),
2388            "first pass fresh result intact"
2389        );
2390        assert_eq!(
2391            second.messages.last().unwrap()["content"].as_str(),
2392            Some(grown_fresh.as_str()),
2393            "second pass fresh result intact"
2394        );
2395        // Count-only passes never feed anti-thrash (F2).
2396        assert!(!state.disabled);
2397        assert_eq!(state.attempts, 0);
2398    }
2399
2400    /// F2: count-only invocations neither feed anti-thrash (poor reclaims
2401    /// never latch) nor consult it (a latched switch must not kill the
2402    /// VRAM guard or convert it into a refused send).
2403    #[tokio::test]
2404    async fn count_only_never_feeds_or_consults_anti_thrash() {
2405        // Poor-reclaim count-only shape: small messages, so replacing the
2406        // middle with the marker reclaims (well) under 10%.
2407        let mut msgs = vec![sys("you are newt"), user("task")];
2408        for i in 0..10 {
2409            msgs.push(user(&format!("note {i}")));
2410        }
2411        let mut state = CompressState::new();
2412        for _ in 0..4 {
2413            let budget = estimate_tokens(&msgs, EST) / 2;
2414            let out = run_count_only(&msgs, budget, Some(6), None, &mut state).await;
2415            assert_ne!(out.action, CompressAction::Refused);
2416        }
2417        assert!(!state.disabled, "count-only passes must never latch");
2418        assert_eq!(state.attempts, 0, "count-only passes must never record");
2419
2420        // A latched state must not block the VRAM guard.
2421        let mut latched = CompressState::new();
2422        latched.disabled = true;
2423        latched.notified = true;
2424        let budget = estimate_tokens(&msgs, EST) / 2;
2425        let out = run_count_only(&msgs, budget, Some(6), None, &mut latched).await;
2426        assert_ne!(out.action, CompressAction::Refused);
2427        assert!(
2428            out.messages.len() < msgs.len(),
2429            "the VRAM guard must stay alive while anti-thrash is latched"
2430        );
2431    }
2432
2433    // -- boundary -------------------------------------------------------------
2434
2435    #[test]
2436    fn boundary_head_is_system_plus_original_task() {
2437        let msgs = tool_heavy("the task", 6, 1_000);
2438        let b = compute_boundary(&msgs, 1_000, None, EST);
2439        assert_eq!(b.head, 2, "system + original task");
2440
2441        // Multiple system messages all land in the head.
2442        let mut msgs2 = vec![sys("a"), sys("b"), user("task"), user("more")];
2443        msgs2.extend(tool_heavy("x", 4, 1_000).split_off(2));
2444        assert_eq!(compute_boundary(&msgs2, 1_000, None, EST).head, 3);
2445    }
2446
2447    #[test]
2448    fn boundary_tail_is_token_budgeted_with_minimum() {
2449        // 10 rounds of ~250-token results; budget 4_000 → tail budget 1_000.
2450        let msgs = tool_heavy("task", 10, 1_000);
2451        let b = compute_boundary(&msgs, 4_000, None, EST);
2452        let tail_tokens: usize = msgs[b.tail_start..]
2453            .iter()
2454            .map(|m| estimate_value_tokens(m, EST))
2455            .sum();
2456        assert!(
2457            tail_tokens <= 1_500,
2458            "tail stays near the token budget, got {tail_tokens}"
2459        );
2460        assert!(
2461            msgs.len() - b.tail_start >= TAIL_MIN_MESSAGES,
2462            "at least the minimum tail"
2463        );
2464        assert!(b.tail_start > b.head, "a middle exists to summarize");
2465
2466        // Huge results: the minimum still applies even over the token budget.
2467        let msgs = tool_heavy("task", 6, 40_000);
2468        let b = compute_boundary(&msgs, 4_000, None, EST);
2469        assert!(msgs.len() - b.tail_start >= TAIL_MIN_MESSAGES);
2470    }
2471
2472    #[test]
2473    fn boundary_anchors_last_user_message_into_tail() {
2474        // A user interjection deep in the middle, then many tool rounds whose
2475        // token mass would normally push the tail cut past it.
2476        let mut msgs = tool_heavy("task", 2, 500);
2477        msgs.push(user("IMPORTANT FOLLOW-UP: also update the docs"));
2478        let follow_up = msgs.len() - 1;
2479        for i in 0..6 {
2480            msgs.push(assistant_call(
2481                "read_file",
2482                json!({"path": format!("f{i}")}),
2483            ));
2484            msgs.push(tool_result(&"q".repeat(4_000)));
2485        }
2486        let b = compute_boundary(&msgs, 2_000, None, EST);
2487        assert!(
2488            b.tail_start <= follow_up,
2489            "tail (start {}) must include the last user message at {follow_up}",
2490            b.tail_start
2491        );
2492    }
2493
2494    /// F1a: the last-user anchor must skip the pipeline's own compaction
2495    /// message — anchoring on it pinned the tail at the marker forever
2496    /// (the middle went empty and nothing could ever shrink again).
2497    #[test]
2498    fn boundary_anchor_skips_compaction_messages() {
2499        let mut msgs = vec![sys("you are newt"), user("the task")];
2500        msgs.push(summary_message("## Active Task\nthe task (summarized)"));
2501        for i in 0..6 {
2502            msgs.push(assistant_call(
2503                "read_file",
2504                json!({"path": format!("f{i}")}),
2505            ));
2506            msgs.push(tool_result(&"q".repeat(4_000)));
2507        }
2508        let b = compute_boundary(&msgs, 2_000, None, EST);
2509        assert!(
2510            b.tail_start > 2,
2511            "the tail must not pin to the compaction message at index 2 \
2512             (tail_start {})",
2513            b.tail_start
2514        );
2515        // A real user follow-up AFTER the marker still anchors.
2516        let mut msgs2 = msgs.clone();
2517        msgs2.push(user("IMPORTANT FOLLOW-UP: also update the docs"));
2518        let follow_up = msgs2.len() - 1;
2519        for _ in 0..4 {
2520            msgs2.push(assistant_call("read_file", json!({"path": "g"})));
2521            msgs2.push(tool_result(&"q".repeat(4_000)));
2522        }
2523        let b2 = compute_boundary(&msgs2, 2_000, None, EST);
2524        assert!(
2525            b2.tail_start <= follow_up,
2526            "a real user message still anchors the tail"
2527        );
2528    }
2529
2530    /// F1d: when the anchored last-user message sits deep before many tool
2531    /// rounds (the multi-turn shape), the count ceiling still caps the
2532    /// tail — otherwise `max_messages` is unreachable and the count
2533    /// trigger re-fires (and re-summarizes) every round.
2534    #[test]
2535    fn boundary_count_cap_holds_after_the_anchor() {
2536        let mut msgs = vec![
2537            sys("you are newt"),
2538            user("turn 1"),
2539            json!({"role": "assistant", "content": "reply 1"}),
2540            user("turn 2"),
2541            json!({"role": "assistant", "content": "reply 2"}),
2542            user("the current task"),
2543        ];
2544        let task_idx = msgs.len() - 1;
2545        for i in 0..12 {
2546            msgs.push(assistant_call(
2547                "read_file",
2548                json!({"path": format!("f{i}")}),
2549            ));
2550            msgs.push(tool_result(&"q".repeat(2_000)));
2551        }
2552        let b = compute_boundary(&msgs, 4_000, Some(10), EST);
2553        let assembled = b.head + 1 + (msgs.len() - b.tail_start);
2554        assert!(
2555            assembled <= 12,
2556            "the anchor must not defeat the count goal (assembled {assembled})"
2557        );
2558        assert!(
2559            b.tail_start > task_idx,
2560            "the cut advanced past the deep anchor (tail_start {})",
2561            b.tail_start
2562        );
2563        // Without a count ceiling the anchor still wins.
2564        let b_token = compute_boundary(&msgs, 4_000, None, EST);
2565        assert!(b_token.tail_start <= task_idx);
2566    }
2567
2568    #[test]
2569    fn boundary_never_splits_a_tool_pair() {
2570        for budget in [1_000usize, 2_000, 4_000, 8_000, 16_000] {
2571            let msgs = tool_heavy("task", 8, 2_000);
2572            let b = compute_boundary(&msgs, budget, None, EST);
2573            assert_ne!(
2574                msgs[b.tail_start]["role"].as_str(),
2575                Some("tool"),
2576                "budget {budget}: tail must not start inside a result group"
2577            );
2578        }
2579    }
2580
2581    /// End-to-end through `compress`: with the cut landing between a call
2582    /// and its results, the assembled output has no orphan halves.
2583    #[tokio::test]
2584    async fn compress_output_has_no_orphan_tool_pairs() {
2585        let msgs = tool_heavy("task", 8, 2_000);
2586        let mut state = CompressState::new();
2587        let out = run(&msgs, 2_500, None, None, &mut state).await;
2588        // Every assistant tool_calls group must be followed by exactly its
2589        // results (positional Ollama dialect: count successor tool messages).
2590        let m = &out.messages;
2591        for (i, msg) in m.iter().enumerate() {
2592            if let Some(tcs) = msg["tool_calls"].as_array() {
2593                let mut following = 0;
2594                for next in &m[i + 1..] {
2595                    if next["role"].as_str() == Some("tool") {
2596                        following += 1;
2597                    } else {
2598                        break;
2599                    }
2600                }
2601                assert_eq!(
2602                    following,
2603                    tcs.len(),
2604                    "message {i}: {} tool_calls need {} contiguous results",
2605                    tcs.len(),
2606                    tcs.len()
2607                );
2608            }
2609        }
2610    }
2611
2612    // -- anti-thrash ------------------------------------------------------------
2613
2614    /// Two consecutive <10% reclaims disable compression, the user is
2615    /// notified exactly once, and further over-budget calls are refused.
2616    #[tokio::test]
2617    async fn anti_thrash_disables_notifies_once_then_refuses() {
2618        // Incompressible over-budget input: user messages only (nothing for
2619        // prune), head+tail protection covering everything (no middle).
2620        let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2621        for i in 0..3 {
2622            msgs.push(user(&format!("note {i}")));
2623        }
2624        let mut state = CompressState::new();
2625
2626        let first = run(&msgs, 100, None, None, &mut state).await;
2627        assert_ne!(first.action, CompressAction::Refused);
2628        assert!(first.notice.is_none(), "one poor pass is not yet thrash");
2629
2630        let second = run(&msgs, 100, None, None, &mut state).await;
2631        let notice = second.notice.expect("second poor pass must notify");
2632        assert!(notice.contains("disabled for this session"), "{notice}");
2633
2634        let third = run(&msgs, 100, None, None, &mut state).await;
2635        assert_eq!(third.action, CompressAction::Refused);
2636        assert!(!third.fired);
2637        assert!(
2638            third.notice.is_none(),
2639            "the notice must be delivered exactly once"
2640        );
2641
2642        // Under-budget calls still pass through untouched while disabled.
2643        let ok = run(&msgs, 100_000, None, None, &mut state).await;
2644        assert_eq!(ok.action, CompressAction::Fit);
2645    }
2646
2647    /// Step 20.3 — the fail-open path. With anti-thrash latched and the
2648    /// context over a NON-authoritative budget (the proven-good HWM alone, no
2649    /// believed window — the cloud / gpt-4.1 case), the send must NOT be
2650    /// refused. Refusing there is the death spiral: it discards the very
2651    /// acceptance evidence that would raise the HWM. Instead the messages pass
2652    /// through unchanged as `DispatchedOverBudget` so the caller dispatches and
2653    /// the backend rules.
2654    #[tokio::test]
2655    async fn non_authoritative_budget_fails_open_instead_of_refusing() {
2656        let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2657        for i in 0..3 {
2658            msgs.push(user(&format!("note {i}")));
2659        }
2660        let mut state = CompressState::new();
2661
2662        // Two incompressible poor passes latch anti-thrash (same as the
2663        // refuse test), but on a non-authoritative budget.
2664        let first = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2665        assert_ne!(first.action, CompressAction::Refused);
2666        let _second = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2667        assert!(state.disabled, "two poor passes must latch the breaker");
2668
2669        // The latched, over-budget third call FAILS OPEN — never Refused.
2670        let third = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2671        assert_eq!(third.action, CompressAction::DispatchedOverBudget);
2672        assert!(!third.fired, "messages pass through unchanged");
2673        assert_eq!(third.messages.len(), msgs.len(), "nothing dropped");
2674        let notice = third.notice.expect("fail-open is surfaced once");
2675        assert!(notice.contains("no authoritative window"), "{notice}");
2676
2677        // And the fail-open notice fires exactly once.
2678        let fourth = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2679        assert_eq!(fourth.action, CompressAction::DispatchedOverBudget);
2680        assert!(fourth.notice.is_none(), "notice delivered exactly once");
2681    }
2682
2683    /// Step 20.3 — the authoritative budget still refuses (B6 preserved): a
2684    /// declared/believed window or cw-400 cap must stop a send the backend
2685    /// would silently head-truncate. Only the lone HWM fails open.
2686    #[tokio::test]
2687    async fn authoritative_budget_still_refuses_when_latched() {
2688        let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2689        for i in 0..3 {
2690            msgs.push(user(&format!("note {i}")));
2691        }
2692        let mut state = CompressState::new();
2693        run(&msgs, 100, None, None, &mut state).await;
2694        run(&msgs, 100, None, None, &mut state).await;
2695        assert!(state.disabled);
2696        let third = run(&msgs, 100, None, None, &mut state).await;
2697        assert_eq!(
2698            third.action,
2699            CompressAction::Refused,
2700            "an authoritative ceiling must still refuse, not truncate"
2701        );
2702    }
2703
2704    /// #6 (D, #661): the complement of the test above — when the middle IS
2705    /// reducible (small head+tail, large summarizable middle), a latched
2706    /// authoritative over-budget call performs a forced static-marker compaction
2707    /// that fits, instead of the dead-end Refused. Refusal is reserved for the
2708    /// truly-irreducible (head+tail alone over budget) case.
2709    #[tokio::test]
2710    async fn latched_authoritative_compacts_to_marker_instead_of_refusing() {
2711        let mut msgs = vec![sys("sys"), user("task")];
2712        for i in 0..24 {
2713            msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2714        }
2715        msgs.push(user("recent tail"));
2716        let mut state = CompressState::new();
2717        state.latch_disabled_for_tests();
2718        let budget = 300; // far below the whole conversation; head+tail+marker fit
2719        let out = run(&msgs, budget, None, None, &mut state).await;
2720        assert_ne!(
2721            out.action,
2722            CompressAction::Refused,
2723            "a reducible middle must compact to a marker, not dead-end"
2724        );
2725        assert!(
2726            out.tokens_after <= budget,
2727            "forced marker compaction must fit the budget ({} > {budget})",
2728            out.tokens_after
2729        );
2730        assert!(out.fired, "the marker compaction changed the working set");
2731    }
2732
2733    #[tokio::test]
2734    async fn compaction_store_captures_redacted_span_and_names_the_handle() {
2735        use crate::agentic::spill::{SessionSpillStore, SpillStore};
2736        // #661 group B: with a compaction store, the evicted middle is stored
2737        // (redacted) and the marker names a `compaction:<id>` retrieval handle —
2738        // progressive disclosure. A secret in the middle is redacted on store.
2739        let compaction = SessionSpillStore::default();
2740        let mut msgs = vec![sys("sys"), user("task")];
2741        // An early-middle message carrying a secret — it will be evicted + stored.
2742        msgs.push(user("config api_key=9f8e7d6c5b4a32100ffee and more"));
2743        for i in 0..24 {
2744            msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2745        }
2746        msgs.push(user("recent tail"));
2747        let mut state = CompressState::new();
2748        let out = compress(
2749            CompressRequest {
2750                messages: &msgs,
2751                budget: 300,
2752                max_messages: None,
2753                task: "task",
2754                hard_budget: true,
2755                authoritative: true,
2756                focus: None,
2757                est: EST,
2758                summary_input_cap_floor_chars: 8_192,
2759                compaction_store: Some(&compaction),
2760            },
2761            None, // no summarizer → static marker; the handle still rides
2762            &mut state,
2763        )
2764        .await;
2765        assert!(out.fired);
2766        // The marker names compaction:s0 so the model can fault the span in.
2767        assert!(
2768            out.messages.iter().any(|m| m["content"]
2769                .as_str()
2770                .is_some_and(|c| c.contains("compaction:s0"))),
2771            "the marker must name the compaction handle"
2772        );
2773        // The store holds the verbatim span — with the secret REDACTED on store.
2774        let span = compaction.fetch("s0").expect("span must be stored");
2775        assert!(
2776            !span.contains("9f8e7d6c5b4a32100ffee"),
2777            "the secret must be redacted before store: {span}"
2778        );
2779        assert!(
2780            span.contains("[REDACTED]"),
2781            "redaction marker present: {span}"
2782        );
2783    }
2784
2785    #[tokio::test]
2786    async fn knowledge_base_stable_base_survives_compression() {
2787        // #661 group E: the knowledge_base technique (FfiSurfaceProvider) injects
2788        // the authoritative import surface into the FROZEN system prompt. head_len
2789        // always protects leading system messages, so that stable base is NEVER
2790        // summarized — the summarizer has less to preserve, and the model keeps an
2791        // exact import surface to ground against. This guards that invariant
2792        // against a future boundary change that might evict the system prompt.
2793        let kb = "## Authoritative import surface\n\
2794                  from newt_agent._newt_agent.core import Router  # real path, not a guess";
2795        let mut msgs = vec![sys(kb), user("task")];
2796        for i in 0..24 {
2797            msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2798        }
2799        msgs.push(user("recent tail"));
2800        let mut state = CompressState::new();
2801        let out = run(&msgs, 300, None, None, &mut state).await;
2802        assert!(out.fired, "a large conversation should compress");
2803        assert!(
2804            out.messages.iter().any(|m| m["role"] == "system"
2805                && m["content"]
2806                    .as_str()
2807                    .is_some_and(|c| c.contains("from newt_agent._newt_agent.core import Router"))),
2808            "the knowledge_base import surface must survive compression VERBATIM \
2809             (the protected head — the stable base E relies on)"
2810        );
2811    }
2812
2813    /// Effective compressions never trip the anti-thrash switch.
2814    #[tokio::test]
2815    async fn effective_compressions_do_not_disable() {
2816        let mut state = CompressState::new();
2817        for _ in 0..4 {
2818            let msgs = tool_heavy("task", 6, 4_000);
2819            let before = estimate_tokens(&msgs, EST);
2820            let out = run(&msgs, before / 3, None, None, &mut state).await;
2821            assert_ne!(out.action, CompressAction::Refused);
2822            assert!(out.notice.is_none());
2823        }
2824        assert!(!state.disabled);
2825    }
2826
2827    /// A good pass between two poor ones resets the "twice in a row" window.
2828    #[test]
2829    fn thrash_window_requires_consecutive_poor_savings() {
2830        let mut state = CompressState::new();
2831        state.record(1_000, 990, 500); // poor
2832        state.record(1_000, 400, 500); // good
2833        state.record(1_000, 990, 500); // poor
2834        assert!(!state.disabled, "non-consecutive poor passes never disable");
2835        state.record(1_000, 950, 500); // poor — now two in a row
2836        assert!(state.disabled);
2837    }
2838
2839    #[test]
2840    fn budget_aware_gap_progress_is_not_a_strike() {
2841        // #661 regression: a pass reclaiming <10% RELATIVE but shrinking the
2842        // over-budget GAP meaningfully is EFFECTIVE — the old relative-only gate
2843        // disabled compression on a tight budget exactly when it mattered.
2844        let mut state = CompressState::new();
2845        // 1000→920 against budget 800: relative 8% (<10%), but gap 200→120 (−40%).
2846        state.record(1_000, 920, 800);
2847        state.record(1_000, 920, 800);
2848        assert!(
2849            !state.is_disabled(),
2850            "gap-shrinking passes must not latch the disable"
2851        );
2852        // A genuinely useless pass (no fit, no gap progress, no abs floor, <10%)
2853        // still strikes twice and latches.
2854        let mut dead = CompressState::new();
2855        dead.record(1_000, 995, 500);
2856        dead.record(1_000, 996, 500);
2857        assert!(dead.is_disabled(), "truly ineffective passes still latch");
2858    }
2859
2860    // -- user-initiated (`/compress`, Step 18.6) ------------------------------
2861
2862    /// Provider-shaped chat history (no tool messages): system, the task,
2863    /// then `turns` user/assistant pairs of `chars` characters each.
2864    fn chat_history(turns: usize, chars: usize) -> Vec<Value> {
2865        let mut msgs = vec![sys("you are newt"), user("ORIGINAL TASK: port the parser")];
2866        for i in 0..turns {
2867            msgs.push(user(&format!("q{i} {}", "u".repeat(chars))));
2868            msgs.push(json!({"role": "assistant",
2869                             "content": format!("a{i} {}", "v".repeat(chars))}));
2870        }
2871        msgs
2872    }
2873
2874    /// `/compress` compresses with NO token pressure (the user asked): the
2875    /// soft aim-to-halve request fires, the message count shrinks, the
2876    /// marked summary is present, and the run records into the counters.
2877    #[tokio::test]
2878    async fn user_initiated_compresses_without_token_pressure() {
2879        let msgs = chat_history(10, 400);
2880        let prompts = Arc::new(Mutex::new(Vec::new()));
2881        let s = recording_summarizer(prompts.clone(), "## Active Task\nMANUAL SUMMARY");
2882        let mut state = CompressState::new();
2883        let out = compress_user_initiated(&msgs, None, Some(&*s), &mut state, EST, 8_192).await;
2884
2885        assert!(out.fired);
2886        assert_eq!(out.how, CompressAction::Summarized.describe());
2887        assert_eq!(out.messages_before, msgs.len());
2888        assert_eq!(out.messages_after, out.messages.len());
2889        assert!(
2890            out.messages_after < out.messages_before,
2891            "count must shrink"
2892        );
2893        assert!(out.tokens_after < out.tokens_before);
2894        assert!(
2895            out.messages.iter().any(|m| is_compaction_message(m)
2896                && m["content"].as_str().unwrap().contains("MANUAL SUMMARY")),
2897            "marked summary message must be present"
2898        );
2899        // The original-task anchor was derived from the working set itself.
2900        let p = prompts.lock().unwrap();
2901        assert!(p[0].contains("ORIGINAL TASK: port the parser"), "{}", p[0]);
2902        // Fired manual runs feed the effectiveness counters.
2903        let c = state.counters();
2904        assert_eq!(c.compressions, 1);
2905        assert_eq!(c.strikes, 0, "a good reclaim is not a strike");
2906        assert!(c.last_reclaim.unwrap() > THRASH_MIN_SAVINGS);
2907        assert!(!c.disabled);
2908    }
2909
2910    /// The `/compress <focus>` topic reaches the summarizer as emphasis
2911    /// guidance — with a credential typed into the focus REDACTED before the
2912    /// request is assembled (the same pass the rendered middle gets).
2913    #[tokio::test]
2914    async fn user_initiated_focus_is_threaded_and_redacted() {
2915        let msgs = chat_history(10, 400);
2916        let prompts = Arc::new(Mutex::new(Vec::new()));
2917        let s = recording_summarizer(prompts.clone(), "SUMMARY");
2918        let mut state = CompressState::new();
2919        let secret = "sk-aaaaaaaaaaaaaaaaaaaaaaaa1234";
2920        let focus = format!("the auth flow around {secret} handling");
2921        let out =
2922            compress_user_initiated(&msgs, Some(&focus), Some(&*s), &mut state, EST, 8_192).await;
2923        assert!(out.fired);
2924
2925        let p = prompts.lock().unwrap();
2926        assert_eq!(p.len(), 1);
2927        assert!(
2928            p[0].contains("emphasize anything about"),
2929            "focus guidance line missing: {}",
2930            p[0]
2931        );
2932        assert!(p[0].contains("the auth flow around"), "{}", p[0]);
2933        assert!(
2934            !p[0].contains(secret),
2935            "a secret typed into the focus must never reach the summarizer"
2936        );
2937        assert!(p[0].contains("[REDACTED]"));
2938    }
2939
2940    /// No focus ⇒ no emphasis guidance in the request (the loop's automatic
2941    /// requests must be byte-identical to pre-18.6 ones).
2942    #[tokio::test]
2943    async fn no_focus_means_no_guidance_line() {
2944        let msgs = chat_history(10, 400);
2945        let prompts = Arc::new(Mutex::new(Vec::new()));
2946        let s = recording_summarizer(prompts.clone(), "SUMMARY");
2947        let mut state = CompressState::new();
2948        compress_user_initiated(&msgs, None, Some(&*s), &mut state, EST, 8_192).await;
2949        assert!(!prompts.lock().unwrap()[0].contains("emphasize anything about"));
2950    }
2951
2952    /// An incompressible working set is a honest no-op: nothing fired,
2953    /// nothing recorded — repeated `/compress` on a tiny session must never
2954    /// strike out auto-compression for later.
2955    #[tokio::test]
2956    async fn user_initiated_noop_records_nothing() {
2957        let msgs = vec![sys("you are newt"), user("task"), user("note")];
2958        let mut state = CompressState::new();
2959        for _ in 0..3 {
2960            let out = compress_user_initiated(&msgs, None, None, &mut state, EST, 8_192).await;
2961            assert!(!out.fired, "nothing to reclaim — must not fire");
2962            assert_eq!(out.messages, msgs);
2963            assert_eq!(out.tokens_before, out.tokens_after);
2964            assert!(out.notice.is_none());
2965        }
2966        let c = state.counters();
2967        assert_eq!(c.compressions, 0, "no-op runs never count");
2968        assert_eq!(c.strikes, 0);
2969        assert!(!c.disabled);
2970        assert_eq!(c.last_reclaim, None);
2971    }
2972
2973    /// `/compress` still runs after anti-thrash latched auto-compression off
2974    /// — the latch gates the automatic hard-budget guard, not an explicit
2975    /// user ask (the soft request never consults it).
2976    #[tokio::test]
2977    async fn user_initiated_runs_while_latched() {
2978        let msgs = chat_history(10, 400);
2979        let mut state = CompressState::new();
2980        state.latch_disabled_for_tests();
2981        let out = compress_user_initiated(&msgs, None, None, &mut state, EST, 8_192).await;
2982        assert!(out.fired, "an explicit ask must bypass the latch");
2983        assert_eq!(out.how, CompressAction::StaticFallback.describe());
2984        assert!(state.is_disabled(), "the latch itself stays set");
2985    }
2986
2987    /// Counters snapshot: a pure projection of the recorded state.
2988    #[test]
2989    fn counters_snapshot_projects_state() {
2990        let mut state = CompressState::new();
2991        let c = state.counters();
2992        assert_eq!((c.compressions, c.strikes, c.disabled), (0, 0, false));
2993        assert_eq!(c.last_reclaim, None);
2994
2995        state.record(1_000, 400, 500); // good: 60% reclaim
2996        let c = state.counters();
2997        assert_eq!((c.compressions, c.strikes, c.disabled), (1, 0, false));
2998        assert!((c.last_reclaim.unwrap() - 0.6).abs() < 0.01);
2999
3000        state.record(1_000, 990, 500); // poor — one strike
3001        let c = state.counters();
3002        assert_eq!((c.compressions, c.strikes, c.disabled), (2, 1, false));
3003
3004        state.record(1_000, 950, 500); // poor — two in a row latches
3005        let c = state.counters();
3006        assert_eq!((c.compressions, c.strikes, c.disabled), (3, 2, true));
3007        assert!(c.last_reclaim.unwrap() < THRASH_MIN_SAVINGS);
3008    }
3009
3010    /// A single poor FIRST attempt is one strike, not two: the [1.0, 1.0]
3011    /// sentinel in the unused slot must never read as a recorded strike.
3012    #[test]
3013    fn counters_first_poor_attempt_is_one_strike() {
3014        let mut state = CompressState::new();
3015        state.record(1_000, 990, 500);
3016        assert_eq!(state.counters().strikes, 1);
3017    }
3018
3019    // -- trigger ------------------------------------------------------------------
3020
3021    #[test]
3022    fn trigger_fires_on_count_token_or_guard() {
3023        // Nothing fired.
3024        assert!(compression_trigger(10, 1_000, 900, 40, None, None, 100).is_none());
3025        // Token threshold (issue #223's crux: count far under threshold).
3026        assert_eq!(
3027            compression_trigger(4, 60_000, 59_000, 40, Some(50_000), None, 100),
3028            Some(CompressTrigger {
3029                budget: 50_000,
3030                max_messages: None,
3031                hard_budget: true,
3032            })
3033        );
3034        // Guard: budget = send_budget − tool schema tokens.
3035        assert_eq!(
3036            compression_trigger(4, 9_000, 8_600, 40, None, Some(8_000), 500),
3037            Some(CompressTrigger {
3038                budget: 7_500,
3039                max_messages: None,
3040                hard_budget: true,
3041            })
3042        );
3043        // Count only: budget halves the MESSAGE-token figure (NOT the
3044        // schema-inclusive current figure — the F1 cross-currency bug),
3045        // max_messages set, and the budget is soft (no anti-thrash).
3046        assert_eq!(
3047            compression_trigger(41, 1_000, 800, 40, None, None, 100),
3048            Some(CompressTrigger {
3049                budget: 400,
3050                max_messages: Some(20),
3051                hard_budget: false,
3052            })
3053        );
3054        // All at once: the tightest token budget wins and stays hard.
3055        assert_eq!(
3056            compression_trigger(41, 60_000, 59_000, 40, Some(50_000), Some(20_000), 500),
3057            Some(CompressTrigger {
3058                budget: 19_500,
3059                max_messages: Some(20),
3060                hard_budget: true,
3061            })
3062        );
3063        // Under-threshold figures don't fire their triggers.
3064        assert!(compression_trigger(4, 7_999, 7_000, 40, Some(50_000), Some(8_000), 0).is_none());
3065    }
3066
3067    /// Re-homed `trim_to_token_budget_zero_is_noop` (F3): a configured zero
3068    /// token budget means DISABLED — `Some(0)` must not fire (the 18.4
3069    /// regression flipped it to "compress to budget zero every round").
3070    #[test]
3071    fn trigger_zero_token_budget_is_disabled() {
3072        assert!(compression_trigger(4, 100, 90, 40, Some(0), None, 0).is_none());
3073        assert!(compression_trigger(4, 100, 90, 40, None, Some(0), 10).is_none());
3074        // Zero token budgets stay disabled while a real count trigger fires.
3075        assert_eq!(
3076            compression_trigger(41, 100, 90, 40, Some(0), Some(0), 10),
3077            Some(CompressTrigger {
3078                budget: 45,
3079                max_messages: Some(20),
3080                hard_budget: false,
3081            })
3082        );
3083    }
3084
3085    // -- redaction ----------------------------------------------------------------
3086
3087    #[test]
3088    fn redaction_catches_true_positives() {
3089        let cases = [
3090            (
3091                "the key is sk-AbCdEf1234567890AbCdEf1234567890",
3092                "sk-AbCdEf",
3093            ),
3094            ("ghp_AbCdEf1234567890AbCdEf1234567890", "ghp_"),
3095            ("github_pat_11ABCDEFG0123456789_abcdefghij", "github_pat_"),
3096            ("aws id AKIAIOSFODNN7EXAMPLE", "AKIAIOSFODNN7"),
3097            (
3098                "Authorization: Bearer abc.def-ghi_jkl012345678901234567890",
3099                "abc.def-ghi",
3100            ),
3101            ("api_key=9f8e7d6c5b4a32100ffee", "9f8e7d6c"),
3102            ("password: \"hunter2hunter2\"", "hunter2hunter2"),
3103            (
3104                "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456",
3105                "eyJhbGci",
3106            ),
3107        ];
3108        for (input, leaked) in cases {
3109            let out = redact_secrets(input);
3110            assert!(
3111                !out.contains(leaked),
3112                "secret fragment {leaked:?} survived: {out}"
3113            );
3114            assert!(out.contains("[REDACTED]"), "no redaction marker: {out}");
3115        }
3116        // A private key block, including an unterminated one.
3117        let key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow…\n-----END RSA PRIVATE KEY-----";
3118        assert!(!redact_secrets(key).contains("MIIEow"));
3119        let cut = "-----BEGIN PRIVATE KEY-----\nMIIEow… (truncated)";
3120        assert!(!redact_secrets(cut).contains("MIIEow"));
3121    }
3122
3123    #[test]
3124    fn redaction_passes_benign_near_misses() {
3125        let benign = [
3126            "the api key is stored in the system keychain",
3127            "the token budget is 4096 tokens per request",
3128            "Bearer of good news: the build is green",
3129            "sk-test was rejected (too short to be a real key)",
3130            "set password: yes in sshd_config",
3131            "AKIAFOO is not a full key id",
3132            "ghp_short",
3133            "the access_token field is documented in docs/api.md",
3134            "run `cargo test -p newt-core` and check the password prompt",
3135        ];
3136        for input in benign {
3137            let out = redact_secrets(input);
3138            assert_eq!(out, input, "benign text must pass unchanged");
3139        }
3140    }
3141
3142    #[test]
3143    fn redaction_applies_inside_the_summary_request() {
3144        let middle = vec![tool_result(
3145            "config: api_key=9f8e7d6c5b4a32100ffee and more text",
3146        )];
3147        let request = redact_secrets(&summary_request(
3148            "the task",
3149            &middle,
3150            usize::MAX,
3151            None,
3152            ConvShape::Coding,
3153        ));
3154        assert!(!request.contains("9f8e7d6c5b4a32100ffee"), "{request}");
3155        assert!(request.contains("api_key=[REDACTED]"), "{request}");
3156        assert!(request.contains("the task"), "task still present verbatim");
3157    }
3158
3159    #[test]
3160    fn middle_shape_detects_coding_vs_general() {
3161        // A4 (#661): a middle that issued tool calls is Coding; pure prose is General.
3162        let coding = vec![serde_json::json!({
3163            "role": "assistant",
3164            "tool_calls": [{"function": {"name": "edit_file", "arguments": "{}"}}],
3165        })];
3166        assert_eq!(middle_shape(&coding), ConvShape::Coding);
3167        let general = vec![
3168            serde_json::json!({"role": "user", "content": "what is a monad?"}),
3169            serde_json::json!({"role": "assistant", "content": "a monoid in ..."}),
3170        ];
3171        assert_eq!(middle_shape(&general), ConvShape::General);
3172    }
3173
3174    #[test]
3175    fn general_shape_swaps_the_section_template() {
3176        // A4 (#661): the General template drops file/action-centric slots for prose,
3177        // but both shapes keep the load-bearing Active Task / Critical Context.
3178        let coding = summary_prompt_for("t", "body", None, None, 600, ConvShape::Coding);
3179        assert!(coding.contains("## Completed Actions") && coding.contains("## Relevant Files"));
3180        let general = summary_prompt_for("t", "body", None, None, 600, ConvShape::General);
3181        assert!(general.contains("## Discussion") && general.contains("## Open Questions"));
3182        assert!(
3183            !general.contains("## Relevant Files"),
3184            "no file-centric slot for a Q&A middle"
3185        );
3186        assert!(general.contains("## Active Task") && general.contains("## Critical Context"));
3187        assert!(general.starts_with("You are compressing the middle of a conversation."));
3188    }
3189
3190    /// F6: tool-call args reach the summarizer rendered AS JSON — the
3191    /// quoted-key credential shape must redact.
3192    #[test]
3193    fn redaction_catches_json_quoted_credential_keys() {
3194        let cases = [
3195            (r#"{"api_key": "9f8e7d6c5b4a32100ffee"}"#, "9f8e7d6c"),
3196            (r#"{"password": "hunter2hunter2"}"#, "hunter2hunter2"),
3197            (
3198                r#"body: "client_secret": "abcd1234efgh5678ijkl""#,
3199                "abcd1234",
3200            ),
3201        ];
3202        for (input, leaked) in cases {
3203            let out = redact_secrets(input);
3204            assert!(
3205                !out.contains(leaked),
3206                "secret fragment {leaked:?} survived: {out}"
3207            );
3208            assert!(out.contains("[REDACTED]"), "no redaction marker: {out}");
3209        }
3210    }
3211
3212    /// N4: redaction runs BEFORE excerpting — a credential the excerpt cap
3213    /// would slice mid-value must not leak a fragment too short for any
3214    /// pattern to match afterward.
3215    #[test]
3216    fn redaction_survives_excerpt_truncation() {
3217        let secret = "sk-AbCdEf1234567890AbCdEf1234567890";
3218        // The serialized args put the secret astride the 200-char arg cap:
3219        // unredacted it would be cut to an unmatchable `sk-…` fragment.
3220        let args = json!({
3221            "command": format!("{} && export OPENAI_API_KEY={secret}", "x".repeat(140))
3222        });
3223        let m = assistant_call("run_command", args);
3224        let line = render_message(&m);
3225        assert!(!line.contains("sk-AbC"), "{line}");
3226        assert!(!line.contains("AbCdEf123"), "no fragment may leak: {line}");
3227        assert!(line.contains("[REDACTED]"), "{line}");
3228    }
3229
3230    /// F5: the rendered middle fed to the summarizer is capped in TOTAL —
3231    /// the most recent middle survives, the oldest is dropped with an
3232    /// explicit omission line (per-message caps alone don't bound a
3233    /// 50-message middle).
3234    #[test]
3235    fn summary_request_caps_total_middle_size() {
3236        let middle: Vec<Value> = (0..50)
3237            .map(|i| tool_result(&format!("MSG{i} {}", "m".repeat(1_900))))
3238            .collect();
3239        let capped = summary_request("the task", &middle, 8_192, None, ConvShape::Coding);
3240        assert!(
3241            capped.chars().count() < 12_000,
3242            "total must be capped, got {}",
3243            capped.chars().count()
3244        );
3245        assert!(capped.contains("older message(s) omitted"), "{capped:.200}");
3246        assert!(capped.contains("MSG49 "), "most recent middle kept");
3247        assert!(!capped.contains("MSG0 "), "oldest middle dropped");
3248        assert!(capped.contains("the task"), "task always present");
3249
3250        // Uncapped baseline for contrast: same middle, no cap.
3251        let uncapped = summary_request("the task", &middle, usize::MAX, None, ConvShape::Coding);
3252        assert!(uncapped.chars().count() > 90_000);
3253        assert!(!uncapped.contains("older message(s) omitted"));
3254    }
3255
3256    // -- chunked / hierarchical summarization (Step 24.4, #559) -------------------
3257
3258    #[test]
3259    fn chunk_strings_groups_consecutive_within_cap() {
3260        let parts: Vec<String> = ["aaa", "bbb", "ccc", "ddddddd"]
3261            .iter()
3262            .map(|s| s.to_string())
3263            .collect();
3264        // cap 6: aaa+bbb=6 ok; +ccc would be 9>6 → new chunk; ccc(3)+ddddddd(7)=10
3265        // >6 → new chunk; ddddddd alone is its own over-cap chunk.
3266        assert_eq!(
3267            chunk_strings(&parts, 6),
3268            vec![
3269                "aaabbb".to_string(),
3270                "ccc".to_string(),
3271                "ddddddd".to_string()
3272            ]
3273        );
3274        // Everything fits → a single chunk.
3275        assert_eq!(chunk_strings(&parts, 1_000).len(), 1);
3276    }
3277
3278    #[tokio::test]
3279    async fn summarize_middle_single_request_when_it_fits() {
3280        let prompts = Arc::new(Mutex::new(Vec::new()));
3281        let s = recording_summarizer(prompts.clone(), "SUMMARY");
3282        let middle = vec![user("alpha"), user("beta")];
3283        let out = summarize_middle(&*s, "do the task", &middle, 100_000, None).await;
3284        assert_eq!(out.as_deref(), Some("SUMMARY"));
3285        assert_eq!(prompts.lock().unwrap().len(), 1, "fits → one request");
3286    }
3287
3288    #[tokio::test]
3289    async fn summarize_middle_chunks_and_reduces_when_over_cap() {
3290        let prompts = Arc::new(Mutex::new(Vec::new()));
3291        let s = recording_summarizer(prompts.clone(), "PART");
3292        // Six ~1000-char messages (~6k rendered) against a 2,500-char cap →
3293        // several bounded chunks + a reduce pass, covering the WHOLE middle.
3294        let big = "x".repeat(1_000);
3295        let middle: Vec<Value> = (0..6).map(|_| user(&big)).collect();
3296        let out = summarize_middle(&*s, "do the task", &middle, 2_500, None).await;
3297        assert_eq!(out.as_deref(), Some("PART"), "result is the reduce output");
3298        let p = prompts.lock().unwrap();
3299        assert!(
3300            p.len() > 1,
3301            "over-cap middle is chunked: {} requests",
3302            p.len()
3303        );
3304        assert!(
3305            p.iter().any(|r| r.contains("[part 1/")),
3306            "chunks carry part labels"
3307        );
3308        assert!(
3309            p.iter().any(|r| r.contains("consolidate")),
3310            "a reduce/consolidation pass ran"
3311        );
3312        // Every request stays bounded (cap + prompt-template overhead) — the
3313        // whole point: no single request can OOM the summarizer.
3314        assert!(
3315            p.iter().all(|r| r.chars().count() < 2_500 + 2_000),
3316            "each request stays under the cap (+ template)"
3317        );
3318    }
3319
3320    #[tokio::test]
3321    async fn summarize_middle_all_chunks_fail_degrades_to_none() {
3322        let calls = Arc::new(AtomicUsize::new(0));
3323        let s = failing_summarizer(calls.clone());
3324        let big = "x".repeat(1_000);
3325        let middle: Vec<Value> = (0..6).map(|_| user(&big)).collect();
3326        let out = summarize_middle(&*s, "task", &middle, 2_500, None).await;
3327        assert!(out.is_none(), "all chunks failing → None (→ static marker)");
3328        assert!(
3329            calls.load(Ordering::SeqCst) >= 3,
3330            "every chunk was attempted, got {}",
3331            calls.load(Ordering::SeqCst)
3332        );
3333    }
3334
3335    // -- rendering ---------------------------------------------------------------
3336
3337    #[test]
3338    fn render_message_includes_calls_and_caps_content() {
3339        let m = assistant_call("read_file", json!({"path": "src/lib.rs"}));
3340        let line = render_message(&m);
3341        assert!(line.starts_with("[assistant] called read_file("), "{line}");
3342        assert!(line.contains("src/lib.rs"), "{line}");
3343
3344        let long = tool_result(&"w".repeat(10_000));
3345        let line = render_message(&long);
3346        assert!(
3347            line.chars().count() < SUMMARY_INPUT_MSG_CAP + 50,
3348            "{}",
3349            line.len()
3350        );
3351        assert!(line.contains('…'));
3352    }
3353}