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