Skip to main content

supercode_reduce/engine/
mod.rs

1//! The reduction layer's vocabulary (SPEC.md A4): addresses, sidecar
2//! pointers, reduction kinds/records, the reduction log, the stub sentinel,
3//! and the `sc.` metadata discipline. Everything downstream (`project`,
4//! `invert`, the CLI) is built on these types; nothing here mutates a
5//! session or a sidecar — this module only describes the shapes.
6//!
7//! Ground rules this module exists to uphold (SPEC.md §1.3): reductions
8//! never touch the sidecar, every reduction is a reversible pointer, and no
9//! reduction is ever silent — it always carries an in-transcript stub
10//! ([`stub`]).
11
12pub mod handoff;
13pub mod normalize;
14pub mod rehydrate;
15pub mod stub;
16pub mod summarize;
17pub(crate) mod supersede;
18
19use std::collections::{HashMap, HashSet};
20use std::path::PathBuf;
21
22use serde::{Deserialize, Serialize};
23
24use supercode_interchange::ChatMessage;
25use supercode_interchange::{estimate_view_tokens, format_commas, Role};
26pub use supercode_interchange::{
27    is_tool_error, mark_tool_error, mark_tool_outcome_unknown, tool_outcome, ToolOutcome,
28    TOOL_ERROR_METADATA_KEY, TOOL_OUTCOME_UNKNOWN_METADATA_KEY,
29};
30
31use crate::{ReductionError as Error, Result};
32
33/// Address in the CANONICAL full view (the `Session` reconstructed from the
34/// sidecar).
35///
36/// NOT a raw-line index: normalization is not 1:1 with raw lines (Claude
37/// `tool_result` blocks split off from the enclosing user record,
38/// `session.rs:726-765`; Codex's `compacted` record clears messages while the
39/// raw log keeps every line, `session.rs:464-471`). `role` rides along as an
40/// integrity cross-check — if the message at `index` isn't `role` any more,
41/// the addressed view has drifted out from under the pointer.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct MessageAddr {
44    /// Position in the canonical full view's message list.
45    pub index: usize,
46    /// The role expected at that position (integrity cross-check).
47    pub role: Role,
48}
49
50/// A pointer from a reduced placeholder back to its original content in the
51/// sidecar.
52///
53/// `invert` (A6) resolves `addr` against the sidecar-reconstructed `Session`
54/// and verifies `content_hash` before ever substituting content back in — a
55/// stale or foreign sidecar can never silently produce the wrong content.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct SidecarPtr {
58    /// Where the original message lives in the canonical full view.
59    pub addr: MessageAddr,
60    /// Removed byte range within the original content; `None` means the
61    /// whole content was removed (as opposed to a sub-span of it).
62    pub span: Option<(usize, usize)>,
63    /// blake3 hex digest of the full original content.
64    pub content_hash: String,
65}
66
67impl SidecarPtr {
68    /// Verify that `candidate` — the content this pointer is presumed to
69    /// resolve to — still hashes to [`Self::content_hash`].
70    ///
71    /// This is the hash-verify primitive `invert` (A6) calls before
72    /// substituting any original back into the full view. Returns `Err` on
73    /// any mismatch (tampered/stale/foreign content) rather than ever
74    /// substituting wrong content silently.
75    pub fn verify(&self, candidate: &[u8]) -> Result<()> {
76        self.verify_hash(&content_hash(candidate))
77    }
78
79    /// Like [`Self::verify`], but takes an already-computed hash directly —
80    /// for callers (e.g. `hash_turns_range`, A10) whose hash formula isn't
81    /// simply "hash these raw bytes" (it's a hash over several messages'
82    /// concatenated wire bytes) but must still fail exactly the same way on
83    /// mismatch.
84    pub fn verify_hash(&self, actual: &str) -> Result<()> {
85        if actual == self.content_hash {
86            Ok(())
87        } else {
88            Err(Error::new(format!(
89                "sidecar pointer hash mismatch: expected {}, got {actual}",
90                self.content_hash
91            )))
92        }
93    }
94}
95
96/// What kind of reduction produced a placeholder, and the data specific to
97/// that kind. String forms (for the [`stub`] grammar) map 1:1 onto these
98/// variants.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub enum ReductionKind {
101    /// A7: an oversized tool result truncated in the view (sidecar keeps the
102    /// full bytes).
103    ToolOutputTruncated {
104        /// Total size of the original content, in bytes.
105        original_bytes: usize,
106        /// Size of the kept prefix, in bytes.
107        kept_bytes: usize,
108    },
109    /// A8: a read-type tool result elided because the file is unchanged on
110    /// disk since it was read.
111    FileReadElided {
112        /// The file that was read.
113        path: PathBuf,
114        /// The read-log entry recording what was read and when.
115        read_log: ReadLogEntry,
116    },
117    /// A9: an image content part redacted to a stub.
118    ImageRedacted {
119        /// Index of the redacted part within the message's `content_parts`.
120        part_index: usize,
121    },
122    /// A10: a contiguous run of old turns cleared from the view.
123    TurnsCleared {
124        /// Address of the first cleared message (inclusive).
125        first: usize,
126        /// Address of the last cleared message (inclusive).
127        last: usize,
128        /// TR-7 (T20): present only when this span's placeholder carries an
129        /// LLM-generated summary paragraph rather than the deterministic
130        /// `[turns cleared]` stub — `None` whenever
131        /// [`ReductionPolicy::summarize_cleared_turns`] is off (the default,
132        /// SPEC.md TR-7 dev/01), or the side-call was skipped/fell back for
133        /// any other reason (below the cost-guard floor, errored, timed
134        /// out). Purely a view-layer/audit annotation: `invert`/`verify_log`
135        /// ignore this field entirely and restore/verify byte-exact
136        /// originals from `first`/`last`/[`Reduction::ptr`] alone, same as
137        /// before this field existed (SPEC.md TR-7 dev/02).
138        summary: Option<SpanSummary>,
139    },
140    /// TR-10: an already-executed, successful tool_use call's disk-persisted
141    /// payload argument (e.g. `write_file`'s `content`) elided from its
142    /// serialized `arguments`, leaving every other argument (e.g. `path`)
143    /// verbatim. The assistant-side twin of A7 (tool RESULTS) / A8 (stale
144    /// file READS): the reduced slot here is a tool_use's arguments, not a
145    /// tool_result's content.
146    ///
147    /// Beyond the spec statement's `original_bytes`/`path`/`content_hash`,
148    /// this carries `call_id`/`field` so a single reduction can address one
149    /// specific tool_call's one specific payload field — necessary since a
150    /// single assistant message can carry more than one `tool_calls` entry
151    /// (mirrors [`Self::ImageRedacted`]'s `part_index` playing the same role
152    /// for `content_parts`).
153    ToolInputElided {
154        /// Byte length of the elided payload field's ORIGINAL value (not the
155        /// whole `arguments` string) — the stub's size figure.
156        original_bytes: usize,
157        /// The file path the payload was written to, when recoverable from a
158        /// sibling `path` argument — `None` if the tool's schema has none.
159        path: Option<PathBuf>,
160        /// blake3 hex digest of the elided payload field's ORIGINAL value.
161        /// Verified before ever restoring it (mirrors [`SidecarPtr::content_hash`],
162        /// but hashes just the field's value — the sub-span actually
163        /// removed); also what [`probe_tool_input_fresh`] compares a fresh
164        /// disk read against for the freshness matrix.
165        content_hash: String,
166        /// The elided tool_call's stable [`supercode_interchange::ToolCall::id`]
167        /// within the addressed assistant message's `tool_calls` —
168        /// disambiguates when a single assistant turn issues more than one
169        /// tool call.
170        call_id: String,
171        /// Name of the elided payload argument field (e.g. `"content"`) —
172        /// which key inside `arguments` was replaced.
173        field: String,
174    },
175    /// T30/TR-4: a bash/exec tool result whose ANSI color codes and
176    /// carriage-return/erase-line/cursor-up redraws were collapsed down to
177    /// their final rendered content ([`normalize::normalize`]). A VIEW
178    /// normalization (SPEC.md B10: lossy presentation over a lossless
179    /// sidecar) — the rendered CONTENT is fully preserved; only presentation
180    /// bytes (escape sequences, superseded redraw frames) are removed.
181    OutputNormalized {
182        /// Total byte size of the raw captured output before normalization.
183        original_bytes: usize,
184        /// Byte size of the normalized (final-rendered) text, excluding the
185        /// honesty trailer appended alongside it in the view.
186        normalized_bytes: usize,
187    },
188    /// TR-3 (T26): a read-type tool result for a file already read earlier
189    /// this session, whose content has since changed — replaced with a
190    /// unified diff against that prior (base) read rather than shown in full,
191    /// because the diff is materially smaller than the full content (see
192    /// [`ReductionPolicy::diff_max_percent`]).
193    ///
194    /// The base is always a genuine full read, resolved straight from the
195    /// canonical message slice (`project_messages`'s `msgs` parameter, never
196    /// mutated) — never a previously-diffed or -elided reduction's own
197    /// (reduced) content, so diffs never compound (SPEC.md TR-3 dev/04:
198    /// "no diff-of-diff").
199    ///
200    /// `ptr` (on the containing [`Reduction`]) addresses THIS read (the
201    /// re-read being replaced) and pins `new_hash` as its content hash —
202    /// `invert`/`expand_reduction` restore the full re-read byte-exact
203    /// through it, identically to [`ReductionKind::FileReadElided`].
204    /// `base`/`base_hash` are extra provenance: which prior read the diff is
205    /// against, and its content hash at diff-mint time, so a caller can tell
206    /// whether that base itself has since drifted.
207    FileReadDiffed {
208        /// The file that was read.
209        path: PathBuf,
210        /// Where the base (prior full) read lives in the canonical full view.
211        base: MessageAddr,
212        /// Hash of the base read's content, at the time this diff was minted.
213        base_hash: ContentHash,
214        /// Hash of this (new) read's full content — equal to `ptr.content_hash`
215        /// on the containing [`Reduction`].
216        new_hash: ContentHash,
217        /// Size of the full new (re-read) content, in bytes.
218        original_bytes: usize,
219        /// Size of the projected unified-diff text, in bytes (excludes the
220        /// stub placeholder line itself).
221        diff_bytes: usize,
222    },
223    /// TR-2 (T15): a tool result byte-identical to an earlier one still
224    /// addressable in the view, replaced by a stub naming the earlier
225    /// ("canonical") instance. `canonical` is informational only — display
226    /// (the stub summary) and rehydration context — never part of the
227    /// restore path: like every other kind, [`SidecarPtr::addr`] on this
228    /// reduction's own [`Reduction::ptr`] points at THIS message's own
229    /// address, so `invert`/`expand_reduction` recover it independent of
230    /// whatever later happens to `canonical`'s own slot (SPEC.md TR-2
231    /// dev/04: the canonical instance may itself be truncated or cleared
232    /// afterward without ever affecting this pointer).
233    DuplicateOutput {
234        /// Where the earlier, byte-identical instance lives in the
235        /// canonical full view, at the moment this reduction was minted.
236        canonical: MessageAddr,
237        /// Total size of the original (duplicated) content, in bytes.
238        original_bytes: usize,
239    },
240    /// TR-6 (T16): a tool result superseded by a LATER result of the SAME
241    /// tool called with the SAME canonicalized arguments
242    /// (`supersede::canonical_key`) — an old failing `cargo test` run
243    /// obsoleted by the newest run, a stale directory listing, an outdated
244    /// `git diff`. Unlike [`Self::DuplicateOutput`] (TR-2), the two contents
245    /// are NOT required to be byte-identical — a stale FAILING run and a
246    /// later PASSING one of the identical command are exactly the case this
247    /// exists for.
248    ///
249    /// `by` is informational only — display (the stub summary) and
250    /// provenance — never part of the restore path: like every other kind,
251    /// [`SidecarPtr::addr`] on this reduction's own [`Reduction::ptr`] points
252    /// at THIS message's own address, so `invert`/`expand_reduction` recover
253    /// it independent of whatever later happens to `by`'s own slot (mirrors
254    /// TR-2 dev/04's guarantee for `DuplicateOutput::canonical`: the
255    /// successor may itself be truncated, superseded again, or cleared
256    /// afterward without ever affecting this pointer).
257    Superseded {
258        /// Where the newer (successor) result lives in the canonical full
259        /// view, at the moment this reduction was minted.
260        by: MessageAddr,
261        /// Total size of the original (superseded) content, in bytes.
262        original_bytes: usize,
263    },
264}
265
266/// A blake3 hex digest, as produced by [`content_hash`]. A type alias only
267/// (not a newtype) — matches every existing hash field in this module
268/// (`SidecarPtr::content_hash`, `ReadLogEntry::content_hash`), which stayed
269/// plain `String` rather than retrofit this alias in place (SPEC.md TR-3:
270/// "keep enum/match additions minimal and localized").
271pub type ContentHash = String;
272
273/// TR-7 (T20) audit metadata for a [`ReductionKind::TurnsCleared`] span whose
274/// placeholder carries an LLM-generated summary. Recorded on the reduction
275/// itself (persisted in the `<name>.reduction.json` sidecar-family file) so
276/// the audit trail survives independent of the exact placeholder rendering
277/// (SPEC.md TR-7 dev/04: "reduction log records model id, prompt version,
278/// and summary hash for every summarized span").
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct SpanSummary {
281    /// Identifier of the model that generated the summary (e.g.
282    /// `"claude-haiku-4-5"`), taken verbatim from
283    /// [`summarize::SpanSummarizer::model_id`].
284    pub model_id: String,
285    /// Version of the fixed, in-repo summarization prompt used
286    /// ([`summarize::PROMPT_VERSION`] at mint time) — never recomputed
287    /// later, so a prompt-wording change never rewrites history for spans
288    /// already summarized under an earlier version.
289    pub prompt_version: String,
290    /// blake3 hex digest of the summary paragraph text (BEFORE the honesty
291    /// banner/id are appended) — verifiable independent of the placeholder's
292    /// exact surrounding punctuation.
293    pub summary_hash: ContentHash,
294}
295
296/// A record of a file read, kept in [`ReductionLog::read_log`] so an
297/// exporter or a later model can always answer "what was read" even when the
298/// read result itself was elided from the view (A8).
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct ReadLogEntry {
301    /// The file that was read.
302    pub path: PathBuf,
303    /// Where the full read result lives in the canonical full view.
304    pub addr: MessageAddr,
305    /// Hash of the read result's content, taken at read time.
306    pub content_hash: String,
307    /// The file's mtime as observed at projection time, if available.
308    pub mtime: Option<i64>,
309}
310
311/// One applied reduction: what kind it was, where it points, its stable id,
312/// and the exact placeholder text standing in for it in the reduced view.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct Reduction {
315    /// Stable id, e.g. `"r0042-9f3c"` — ordinal + 4-hex content-hash prefix
316    /// (D2). Unique per session; stable across re-projection.
317    pub id: String,
318    /// What was reduced and the kind-specific data.
319    pub kind: ReductionKind,
320    /// Pointer back to the original content in the sidecar.
321    pub ptr: SidecarPtr,
322    /// The exact stub text ([`stub::format`]) standing in for the original
323    /// content in the reduced view.
324    pub placeholder: String,
325}
326
327/// Durable, content-free accounting for one reduction pass. This is proof
328/// metadata only: inversion and projection depend exclusively on
329/// [`ReductionLog::reductions`]. Keeping it with the log lets an offline
330/// inspector distinguish a disabled pass, an enabled pass with no candidate,
331/// and a candidate later subsumed by a higher-order pass such as A10.
332#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
333pub struct ReductionPassAttribution {
334    /// Stable stub-grammar pass name.
335    pub kind: String,
336    /// Whether the final configured policy enabled this pass.
337    pub enabled: bool,
338    /// Claims produced immediately before later cardinality-changing passes.
339    pub candidate_count: usize,
340    /// Original bytes addressed by those claims.
341    pub candidate_original_bytes: u64,
342    /// Claims retained in the final persisted reduction index.
343    pub applied_count: usize,
344    /// Original bytes addressed by retained claims.
345    pub applied_original_bytes: u64,
346    /// Earlier claims subsumed by a later pass.
347    pub suppressed_by_later_pass_count: usize,
348    /// Original bytes addressed by those subsumed claims.
349    pub suppressed_by_later_pass_bytes: u64,
350    /// Bytes this pass would save in isolation.
351    pub standalone_saved_bytes: u64,
352    /// Estimated tokens this pass would save in isolation.
353    pub standalone_saved_tokens: u64,
354    /// Bytes this pass adds after earlier persisted passes.
355    pub marginal_saved_bytes: u64,
356    /// Estimated tokens this pass adds after earlier persisted passes.
357    pub marginal_saved_tokens: u64,
358    /// Standalone byte claim removed by overlap or pass order.
359    pub suppressed_bytes: u64,
360    /// Standalone token claim removed by overlap or pass order.
361    pub suppressed_tokens: u64,
362    /// Projected bytes retained after this pass in pipeline order.
363    pub retained_bytes: u64,
364    /// Estimated tokens retained after this pass in pipeline order.
365    pub retained_tokens: u64,
366}
367
368/// Durable aggregate for [`ReductionPassAttribution`]. Byte and token
369/// aggregates are marginal sums, never sums of overlapping standalone
370/// claims.
371#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
372pub struct ReductionAttribution {
373    /// Serialized bytes before reduction.
374    pub full_bytes: u64,
375    /// Serialized bytes after all persisted reductions.
376    pub view_bytes: u64,
377    /// Estimated tokens before reduction.
378    pub full_tokens: u64,
379    /// Estimated tokens after all persisted reductions.
380    pub view_tokens: u64,
381    /// Actual aggregate byte savings.
382    pub aggregate_saved_bytes: u64,
383    /// Actual aggregate estimated-token savings.
384    pub aggregate_saved_tokens: u64,
385    /// Sum of byte savings claimed by passes in isolation.
386    pub standalone_saved_bytes: u64,
387    /// Sum of estimated-token savings claimed by passes in isolation.
388    pub standalone_saved_tokens: u64,
389    /// Standalone byte claims excluded from the aggregate.
390    pub overlap_suppressed_bytes: u64,
391    /// Standalone estimated-token claims excluded from the aggregate.
392    pub overlap_suppressed_tokens: u64,
393    /// One row per pass in production pipeline order.
394    pub passes: Vec<ReductionPassAttribution>,
395}
396
397/// The persisted index of every reduction applied to a session, plus the A8
398/// read-log. This is the `<name>.reduction.json` sidecar-family file (D1);
399/// `invert` needs it (with the sidecar) to reconstruct the full view.
400#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
401pub struct ReductionLog {
402    /// Every reduction applied so far, in application order.
403    pub reductions: Vec<Reduction>,
404    /// Reductions explicitly rehydrated by the user. They remain persisted
405    /// so a later projection/restart can distinguish "deliberately
406    /// expanded" from "never reduced" without re-stubbing the address.
407    #[serde(default, skip_serializing_if = "Vec::is_empty")]
408    pub expanded: Vec<Reduction>,
409    /// Every file read observed during projection (A8), independent of
410    /// whether that particular read ended up elided.
411    pub read_log: Vec<ReadLogEntry>,
412    /// Optional content-free pass attribution captured by a driving surface.
413    /// Older logs omit it and remain fully compatible.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub attribution: Option<ReductionAttribution>,
416}
417
418/// The sentinel prefix every reduction placeholder starts with (D2).
419///
420/// Defined once, here. `A11`'s export leak-guard greps for this string; the
421/// projection layer never writes it into genuine (non-reduced) content.
422pub const REDUCTION_SENTINEL: &str = "[sc-reduced";
423
424/// The reserved `ChatMessage.metadata` key carrying a reduced message's
425/// [`Reduction::id`] (the `sc.` prefix is reserved for this reduction
426/// layer's own bookkeeping).
427///
428/// This rides `ChatMessage`'s guarantee that `metadata` never serializes to
429/// the wire (`message.rs:49-54`, `57-79`) — the pointer reaches every
430/// in-process consumer (CLI inspection, `invert`) but can never enter a
431/// request body or a persisted transcript.
432pub const REDUCTION_METADATA_KEY: &str = "sc.reduction";
433
434/// Stamp `msg` with the `sc.reduction` metadata key pointing at `id`. This is
435/// the one place a reduction's id is attached to a message; every A/B
436/// emitter should go through this rather than writing the key by hand.
437pub fn set_reduction_id(msg: &mut ChatMessage, id: &str) {
438    msg.metadata
439        .insert(REDUCTION_METADATA_KEY.to_string(), id.to_string());
440}
441
442/// Read back a message's `sc.reduction` id, if it was reduced.
443pub fn reduction_id(msg: &ChatMessage) -> Option<&str> {
444    msg.metadata.get(REDUCTION_METADATA_KEY).map(String::as_str)
445}
446
447/// Hash helper used consistently across the reduction layer: blake3 hex
448/// digest of `bytes`. Used both for [`SidecarPtr::content_hash`] and
449/// [`ReadLogEntry::content_hash`].
450pub fn content_hash(bytes: &[u8]) -> String {
451    blake3::hash(bytes).to_hex().to_string()
452}
453
454/// Build a [`Reduction::id`]: a zero-padded 4-digit ordinal plus the first 4
455/// hex characters of a content hash (D2), e.g. `"r0042-9f3c"`.
456///
457/// `ordinal` is the reduction's position among reductions applied to this
458/// session; it wraps decoratively past 9999 (id uniqueness within a session
459/// still holds in practice because the hash prefix disambiguates, and no
460/// real session approaches that many reductions).
461pub fn make_id(ordinal: usize, hash: &str) -> String {
462    let ord = ordinal % 10_000;
463    let prefix: String = hash.chars().take(4).collect();
464    format!("r{ord:04}-{prefix}")
465}
466
467// ---------------------------------------------------------------------------
468// A5 — project(): session -> reduced view + reduction log
469// ---------------------------------------------------------------------------
470
471/// Knobs controlling [`project_messages`]. Defaults match SPEC.md A5/A7, stacked per
472/// D14 with the levers that don't need an external I/O probe to be safe
473/// on-by-default (`redact_images`; contrast `elide_stale_reads`, below).
474///
475/// `elide_stale_reads` (A8) and `clear_turns_older_than` (A10) are plumbed
476/// through the struct but inert by default — `elide_stale_reads` needs a
477/// freshness probe ([`probe_read_freshness`]) to mean anything, and
478/// `clear_turns_older_than` is populated per-agent from
479/// `compact_after_messages` (`Agent::maybe_compact`), not from this default.
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct ReductionPolicy {
482    /// Bytes kept from the front of an oversized tool result (A7). Default
483    /// `4096`.
484    pub tool_output_keep_bytes: usize,
485    /// Only tool results strictly larger than this are truncation candidates
486    /// (A7). Default `8192`.
487    pub tool_output_trigger_bytes: usize,
488    /// Never reduce the newest `N` tool results (#1 "keep"). Default `3`.
489    pub protect_last_n_tool_results: usize,
490    /// A8 — gate for stale-file-read elision. Consults
491    /// [`Self::read_freshness`] for the actual per-message verdicts; setting
492    /// this without ever populating `read_freshness` (via
493    /// [`probe_read_freshness`]) elides nothing, since the empty default
494    /// freshness map treats every read as not-yet-verified.
495    pub elide_stale_reads: bool,
496    /// A9 — gate for `data:` URL image redaction. Default `true` (D14:
497    /// reduced mode stacks every lossless lever on together) — unlike
498    /// `elide_stale_reads`, this rule is a pure function of the message
499    /// content already in view, so it carries none of A8's "meaningless
500    /// without a probe" caveat and can safely default on.
501    pub redact_images: bool,
502    /// A9 — minimum byte length of a candidate `image_url` part's `url`
503    /// string for it to become a redaction candidate; inert unless
504    /// `redact_images` is set. Default `8192` (mirrors
505    /// `tool_output_trigger_bytes`'s scale: small inline icons stay in view,
506    /// real screenshots/photos get redacted).
507    pub image_redact_min_bytes: usize,
508    /// A10 — inert until turn-clearing lands.
509    pub clear_turns_older_than: Option<usize>,
510    /// A8 — the disk-probe pre-pass's output ([`probe_read_freshness`]),
511    /// consulted by [`project_messages`] only when [`Self::elide_stale_reads`]
512    /// is set. This is *data*, not a config knob: it is meant to be
513    /// recomputed by the caller before every `project`/`project_messages`
514    /// call (disk state can change turn to turn) — `project_messages` itself
515    /// never performs the I/O; that happens once, up front, in
516    /// `probe_read_freshness`. Default: empty (fails closed — nothing is
517    /// considered fresh without an accompanying probe).
518    pub read_freshness: ReadFreshness,
519    /// TR-3 (T26) — gate for diff-only re-read representation
520    /// (`ReductionKind::FileReadDiffed`). Like `redact_images` (and unlike
521    /// `elide_stale_reads`), this rule is a pure function of the message
522    /// content already in view — a re-read's content compared against the
523    /// prior read of the same path recorded in `log.read_log` — with no
524    /// disk-probe caveat, so it can safely default on. Default `true`.
525    pub diff_rereads: bool,
526    /// TR-3 — a candidate diff must be no more than this percentage of the
527    /// full re-read's size to replace it; otherwise the full re-read stays
528    /// untouched (SPEC.md TR-3 dev/03's "large-change guard"). An integer
529    /// percentage (rather than a float) so [`ReductionPolicy`] keeps its
530    /// `Eq` derive (`f64` has none). Default `50` ("diff ≤ 50% of full
531    /// content").
532    pub diff_max_percent: u32,
533    /// B7 coordination clamp: when an imported-prefix cache plan is
534    /// active, the count of leading messages (of the slice passed to
535    /// [`project_messages`]) that make up the imported session prefix. A10
536    /// turn-clearing must never establish a clear range that dips into them,
537    /// since doing so would bust the prefix's cache breakpoint (and its
538    /// fidelity). `None` (the default) applies no clamp, matching today's
539    /// behavior for callers that never set a cache plan. Set by a runtime
540    /// agent from its own `imported_prefix_len`, not a
541    /// user-facing knob.
542    pub protect_imported_prefix: Option<usize>,
543    /// TR-10 — gate for tool-INPUT elision ([`ReductionKind::ToolInputElided`]).
544    /// Default `true`. Unlike `elide_stale_reads`, the candidate rule
545    /// (executed successfully + oversized payload + a disk-persisting tool)
546    /// is a pure function of the view plus [`Self::tool_input_elidable_fields`]
547    /// — no external disk probe is needed to decide elision itself (a probe
548    /// only matters later, for the freshness-matrix ESCALATION decision, see
549    /// [`probe_tool_input_fresh`]) — so, like `redact_images`, this can
550    /// safely default on (D14: reduced mode stacks every lossless lever
551    /// together).
552    pub elide_tool_inputs: bool,
553    /// TR-10 — only a candidate tool_call's payload field whose value
554    /// exceeds this many bytes becomes an elision candidate. Default `8192`
555    /// (mirrors A7/A9's scale).
556    pub tool_input_trigger_bytes: usize,
557    /// TR-10 — table of tool name -> its elidable (disk-persisted) payload
558    /// argument field. Defaults to the built-in write-family tools
559    /// (`write_file` -> `content`, see `default_tool_input_elidable_fields`);
560    /// an MCP tool opts in by inserting its own `(name, field)` entry
561    /// (SPEC.md TR-10: "per-MCP-tool opt-in").
562    pub tool_input_elidable_fields: HashMap<String, String>,
563    /// T30/TR-4 — gate for [`ReductionKind::OutputNormalized`] (ANSI/redraw
564    /// collapse over terminal tool output). Default `true`: like
565    /// `redact_images`, this is a pure function of already-in-view content
566    /// (no I/O probe needed) and content-lossless (rendered CONTENT is fully
567    /// preserved, only presentation bytes are removed), so it stacks on by
568    /// default per D14.
569    pub normalize_terminal_output: bool,
570    /// T30/TR-4 — minimum byte savings (`original_bytes - normalized_bytes`)
571    /// for a candidate to actually become an
572    /// [`ReductionKind::OutputNormalized`] reduction; below this floor the
573    /// output is left untouched rather than raced through the reduction
574    /// machinery for a few bytes (SPEC.md TR-4's "savings floor" knob).
575    /// Default [`normalize::DEFAULT_MIN_SAVINGS`].
576    pub terminal_output_min_savings: usize,
577    /// TR-2 — minimum byte length of a duplicate tool-result candidate's
578    /// content for it to become a [`ReductionKind::DuplicateOutput`]
579    /// candidate. Below this, both the canonical and the would-be duplicate
580    /// are left alone: a stub's own bytes are not free, so deduping a tiny
581    /// output would spend more than it saves (the "savings floor"). Default
582    /// `256`.
583    pub duplicate_output_min_bytes: usize,
584    /// TR-2 — gate for [`ReductionKind::DuplicateOutput`]. Default `true`:
585    /// duplicate detection is a pure function of the recorded tool outputs,
586    /// so it stacks on in ordinary reduced mode. Composable capability
587    /// profiles can disable it without changing the savings-floor knob.
588    pub deduplicate_outputs: bool,
589    /// TR-6 (T16) — gate for [`ReductionKind::Superseded`] (same tool + same
590    /// canonicalized arguments, keep only the newest result). Default
591    /// `true`: like `redact_images`/`elide_tool_inputs`, the candidate rule
592    /// is a pure function of the view plus [`Self::supersede_command_fields`]
593    /// — no external disk probe needed — so it stacks on by default (D14).
594    pub supersede_enabled: bool,
595    /// TR-6 — protected recency zone (opencode's `PRUNE_PROTECT` spirit): the
596    /// newest `N` tool RESULT messages (by position, mirrors
597    /// [`Self::protect_last_n_tool_results`]'s own construction) are never a
598    /// *new* `Superseded` candidate, regardless of how many older same-key
599    /// occurrences exist. A DEDICATED knob rather than reusing
600    /// `protect_last_n_tool_results` — TR-6.md's spec calls this out as its
601    /// own independently tunable "protected recency zone," and the two
602    /// passes run at different points in the pipeline (Superseded runs
603    /// before A7 truncation ever computes its own candidates). Default `3`
604    /// (mirrors `protect_last_n_tool_results`'s own default).
605    pub supersede_protect_last_n: usize,
606    /// TR-6 — minimum byte length of a superseded-candidate's OWN content for
607    /// it to become a [`ReductionKind::Superseded`] candidate (the "savings
608    /// floor," mirrors [`Self::duplicate_output_min_bytes`]). Below this the
609    /// older result is left untouched — a stub's own bytes are not free.
610    /// Default `256`.
611    pub supersede_min_bytes: usize,
612    /// TR-6 — table of tool name -> its command-bearing argument field (e.g.
613    /// `bash`/`shell`/`exec_command` -> `"command"`), consulted by
614    /// `supersede::canonical_key` to canonicalize (trim + collapse internal
615    /// whitespace) just that one field's value rather than the whole
616    /// arguments string. A tool absent from this table still participates in
617    /// supersession — its whole (trimmed-only) arguments string becomes the
618    /// key — this table only controls whitespace-collapse scope, not
619    /// eligibility. Defaults to `supersede::default_command_fields`.
620    pub supersede_command_fields: HashMap<String, String>,
621    /// TR-6 — gate for errored-call input pruning: a FAILED tool call's
622    /// oversized payload argument ([`Self::tool_input_elidable_fields`],
623    /// shared with TR-10) becomes a [`ReductionKind::ToolInputElided`]
624    /// candidate once [`Self::errored_input_prune_after_turns`] assistant
625    /// turns have elapsed since the failed call — the disjoint, FAILURE-side
626    /// complement of TR-10's `elide_tool_inputs` (which only ever considers
627    /// SUCCESSFUL calls; see `detect_tool_inputs`'s own doc comment for the
628    /// success/failure boundary). The failed call's own error-result message
629    /// is never touched by this — only the assistant-side input argument —
630    /// so the error itself stays visible exactly as TR-6.md requires. Default
631    /// `true` (pure function of the view + the age clock, no I/O probe
632    /// needed, D14).
633    pub prune_errored_inputs: bool,
634    /// TR-6 — how many LATER `Role::Assistant` messages must appear after a
635    /// failed call's own message before its oversized input becomes a
636    /// pruning candidate (the "N turns" aging clock in TR-6.md's errored-call
637    /// case) — a message-count proxy for "turns elapsed," the same
638    /// convention [`Self::clear_turns_older_than`] (A10) already uses (this
639    /// codebase has no other structural definition of a conversational
640    /// turn). Default `3`.
641    pub errored_input_prune_after_turns: usize,
642    /// TR-7 (T20) — the `summaries: on|off` config knob: gate for rendering
643    /// an established [`ReductionKind::TurnsCleared`] span's placeholder as
644    /// an LLM-generated summary paragraph instead of the deterministic
645    /// `[turns cleared]` stub. **Default `false`** — SPEC.md TR-7 dev/01:
646    /// with this off, the A10 stub must stay byte-identical to pre-TR-7
647    /// behavior, so `project_messages` never even looks at
648    /// [`Self::cleared_turns_summary`] while this is unset, regardless of
649    /// what a caller precomputed. Turning this on with no matching
650    /// [`Self::cleared_turns_summary`] entry (e.g. the side-call was never
651    /// run, or failed) is exactly as safe: the deterministic stub is still
652    /// what gets rendered (dev/03's failure-fallback guarantee).
653    pub summarize_cleared_turns: bool,
654    /// TR-7 — cost-guard floor (dev/05): a candidate cleared span's ORIGINAL
655    /// byte size (the same `range_bytes` the deterministic stub's own
656    /// summary clause already reports) must exceed
657    /// `expected_summary_bytes * summary_cost_floor_multiple` before
658    /// [`prepare_cleared_turns_summary`] ever calls the injected
659    /// [`summarize::SpanSummarizer`] — below the floor, a summarization
660    /// side-call would be negative-ROI (the stub it replaces is already
661    /// small) and is skipped outright, never attempted. Default `400`
662    /// (a rough paragraph-sized estimate).
663    pub expected_summary_bytes: usize,
664    /// TR-7 — see [`Self::expected_summary_bytes`]; the floor multiplier.
665    /// Default `4` (the span must be at least ~4 summaries' worth of bytes).
666    pub summary_cost_floor_multiple: usize,
667    /// TR-7 — the side-call preparer's output
668    /// ([`prepare_cleared_turns_summary`]), consulted by
669    /// [`project_messages`] only when [`Self::summarize_cleared_turns`] is
670    /// set AND the prepared entry's `(first, last)` matches EXACTLY the
671    /// range `project_messages` independently (re)computes for this call —
672    /// any other prepared entry (stale, wrong range, or simply absent) is
673    /// silently ignored and the deterministic stub is rendered instead. This
674    /// is *data*, not a config knob (mirrors [`Self::read_freshness`]):
675    /// recomputed by the caller (via `prepare_cleared_turns_summary`, the
676    /// one place TR-7's side-call happens) before every
677    /// `project`/`project_messages` call that might establish a NEW
678    /// `TurnsCleared` range. Default: `None`.
679    pub cleared_turns_summary: Option<PreparedClearSummary>,
680}
681
682impl Default for ReductionPolicy {
683    fn default() -> Self {
684        ReductionPolicy {
685            tool_output_keep_bytes: 4096,
686            tool_output_trigger_bytes: 8192,
687            protect_last_n_tool_results: 3,
688            elide_stale_reads: false,
689            redact_images: true,
690            image_redact_min_bytes: 8192,
691            clear_turns_older_than: None,
692            read_freshness: ReadFreshness::default(),
693            diff_rereads: true,
694            diff_max_percent: 50,
695            protect_imported_prefix: None,
696            elide_tool_inputs: true,
697            tool_input_trigger_bytes: 8192,
698            tool_input_elidable_fields: default_tool_input_elidable_fields(),
699            normalize_terminal_output: true,
700            terminal_output_min_savings: normalize::DEFAULT_MIN_SAVINGS,
701            duplicate_output_min_bytes: 256,
702            deduplicate_outputs: true,
703            supersede_enabled: true,
704            supersede_protect_last_n: 3,
705            supersede_min_bytes: 256,
706            supersede_command_fields: supersede::default_command_fields(),
707            prune_errored_inputs: true,
708            errored_input_prune_after_turns: 3,
709            summarize_cleared_turns: false,
710            expected_summary_bytes: 400,
711            summary_cost_floor_multiple: 4,
712            cleared_turns_summary: None,
713        }
714    }
715}
716
717/// The built-in write-family default for
718/// [`ReductionPolicy::tool_input_elidable_fields`]: `write_file` -> `content`
719/// (`tools/builtins.rs`'s `WriteFileTool` schema — the one built-in tool
720/// whose entire payload is disk-persisted verbatim), plus Claude Code's own
721/// native `Write` tool (imported sessions carry Claude's tool names verbatim,
722/// `session.rs::push_claude_assistant` — never remapped to this crate's own
723/// builtin names), which shares the same `content` payload field name.
724fn default_tool_input_elidable_fields() -> HashMap<String, String> {
725    let mut m = HashMap::new();
726    m.insert("write_file".to_string(), "content".to_string());
727    m.insert("Write".to_string(), "content".to_string());
728    m
729}
730
731// ---------------------------------------------------------------------------
732// TR-7 (T20) — LLM-written summary placeholders over cleared spans
733// ---------------------------------------------------------------------------
734
735/// The output of [`prepare_cleared_turns_summary`] — one summarized span,
736/// ready for [`project_messages`] to apply IF (and only if) it independently
737/// recomputes the exact same `(first, last)` range for its own
738/// [`ReductionKind::TurnsCleared`] candidate this call. Threaded through
739/// [`ReductionPolicy::cleared_turns_summary`]; see that field's doc comment
740/// for the full data-vs-config-knob split (mirrors [`ReadFreshness`]).
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub struct PreparedClearSummary {
743    /// Address of the first message the summarized span covers (must match
744    /// `project_messages`'s own candidate range exactly to be applied).
745    pub first: usize,
746    /// Address of the last message the summarized span covers.
747    pub last: usize,
748    /// The summarizer's output, already sanitized into the [`stub`] grammar's
749    /// one-line/no-`]` contract ([`prepare_cleared_turns_summary`] does this
750    /// once, here, so `project_messages` never needs to).
751    pub text: String,
752    /// [`summarize::SpanSummarizer::model_id`], carried through for the
753    /// audit trail ([`SpanSummary::model_id`]).
754    pub model_id: String,
755}
756
757/// Compute the A10 candidate clear range `[first, last]` (inclusive) for
758/// `msgs` under `policy.clear_turns_older_than`/`policy.protect_imported_prefix`,
759/// or `None` if clearing doesn't trigger (below threshold, or no room once
760/// the system-prefix/tool-boundary/imported-prefix guards are applied).
761///
762/// A pure function of `msgs.len()` and each message's `role` alone — never
763/// affected by any in-place content reduction (A7/A8/A9/TR-2/TR-3/TR-4/TR-6/
764/// TR-10 all preserve `role`, only ever rewriting `content`/`tool_calls`), so
765/// it is safe to call directly against the pristine `msgs` slice from BOTH
766/// call sites that must agree on the identical range or a prepared summary
767/// could silently apply to the wrong span: [`project_messages`] itself
768/// (called against `view`, whose roles are identical to `msgs`'s at the
769/// point A10 runs — see its own comment on pass order) and
770/// [`prepare_cleared_turns_summary`] (called against `msgs` directly, before
771/// any projection has run at all). Sharing this one function is what
772/// guarantees the two agree BY CONSTRUCTION, never by convention.
773fn compute_clear_range(msgs: &[ChatMessage], policy: &ReductionPolicy) -> Option<(usize, usize)> {
774    let threshold = policy.clear_turns_older_than?;
775    if msgs.len() <= threshold {
776        return None;
777    }
778    // Never target a leading system/developer message (A5).
779    let mut first = 0;
780    while first < msgs.len() && msgs[first].role == Role::System {
781        first += 1;
782    }
783    // B7 coordination clamp (see `ReductionPolicy::protect_imported_prefix`).
784    if let Some(protected) = policy.protect_imported_prefix {
785        first = first.max(protected);
786    }
787    let keep_recent = (threshold / 2).max(2);
788    let mut cut = msgs.len().saturating_sub(keep_recent);
789    // Never begin the kept (surviving) window on a tool result.
790    while cut < msgs.len() && msgs[cut].role == Role::Tool {
791        cut += 1;
792    }
793    if cut > first && cut < msgs.len() {
794        Some((first, cut - 1))
795    } else {
796        None
797    }
798}
799
800/// Render a message range as the plain, human-legible text fed to
801/// [`summarize::SpanSummarizer::summarize`] (via [`summarize::render_prompt`]
802/// for a real implementation) — one `role: content` line per message. Content
803/// only (not tool-call argument JSON): keeps the side-call's input compact,
804/// matching what TR-7's spec calls "what content existed" rather than a
805/// byte-exact re-serialization (the sidecar, not this rendering, is the
806/// byte-exact source of truth `invert`/`expand_reduction` always use).
807fn render_span_text(msgs: &[ChatMessage]) -> String {
808    let mut out = String::new();
809    for m in msgs {
810        let role = match m.role {
811            Role::System => "system",
812            Role::User => "user",
813            Role::Assistant => "assistant",
814            Role::Tool => "tool",
815        };
816        out.push_str(role);
817        out.push_str(": ");
818        out.push_str(m.content.as_deref().unwrap_or(""));
819        out.push('\n');
820    }
821    out
822}
823
824/// TR-7's one side-call site (SPEC.md: "an explicit, budgeted, injectable
825/// side-call... never blocking the main loop"). Call this against the exact
826/// same `msgs` slice about to be projected (mirrors
827/// [`probe_read_freshness`]'s own calling convention), thread the result
828/// through [`ReductionPolicy::cleared_turns_summary`] before calling
829/// [`project_messages`]. Only ever does anything when
830/// `policy.summarize_cleared_turns` is set; callers that never enable TR-7
831/// can skip calling this entirely (dev/01: `project_messages` behaves
832/// identically either way when the policy gate is off).
833///
834/// Returns `None` — meaning `project_messages` will render the deterministic
835/// stub — whenever: TR-7 is off; a `TurnsCleared` range is already
836/// established (A10 fires at most once per session, singleton, so no
837/// side-call is ever needed for an already-decided span); no clear range
838/// currently triggers; the candidate span is below the cost-guard floor
839/// (dev/05, no side-call attempted at all); or the summarizer itself errors
840/// or returns an empty/blank result (dev/03's fault-injection contract —
841/// this NEVER propagates an error to the caller, by design).
842pub fn prepare_cleared_turns_summary(
843    msgs: &[ChatMessage],
844    policy: &ReductionPolicy,
845    prior: &ReductionLog,
846    summarizer: &dyn summarize::SpanSummarizer,
847) -> Option<PreparedClearSummary> {
848    if !policy.summarize_cleared_turns {
849        return None;
850    }
851    if prior
852        .reductions
853        .iter()
854        .any(|r| matches!(r.kind, ReductionKind::TurnsCleared { .. }))
855    {
856        return None; // Already established; never recomputed (A10 singleton).
857    }
858    let (first, last) = compute_clear_range(msgs, policy)?;
859    let range = &msgs[first..=last];
860    let (_hash, range_bytes) = hash_turns_range(range).ok()?;
861    // Cost guard (dev/05): skip the side-call outright for a span too small
862    // to be positive-ROI once the summary's own stub overhead is counted —
863    // never merely discard a result already paid for.
864    let floor = policy
865        .expected_summary_bytes
866        .saturating_mul(policy.summary_cost_floor_multiple);
867    if range_bytes <= floor {
868        return None;
869    }
870    let span_text = render_span_text(range);
871    let text = match summarizer.summarize(&span_text) {
872        Ok(t) if !t.trim().is_empty() => t,
873        _ => return None, // dev/03: error or blank result -> deterministic fallback.
874    };
875    // Sanitize into the `stub` grammar's one-line, no-`]` contract
876    // (SPEC.md C2/D2) regardless of what the summarizer produced — a
877    // formatting quirk in the model's output must never fail the pass.
878    let sanitized = text
879        .split_whitespace()
880        .collect::<Vec<_>>()
881        .join(" ")
882        .replace(']', ")");
883    if sanitized.is_empty() {
884        return None;
885    }
886    Some(PreparedClearSummary {
887        first,
888        last,
889        text: sanitized,
890        model_id: summarizer.model_id().to_string(),
891    })
892}
893
894// ---------------------------------------------------------------------------
895// A8 — stale-file-read detection + the disk-probe pre-pass
896// ---------------------------------------------------------------------------
897
898/// Read-type tool names A8 elision applies to. A tool result is a candidate
899/// only when its paired assistant `tool_calls` entry names one of these
900/// (`tools/builtins.rs:39-104`'s `read_file`). `B6` must keep this in sync
901/// with any built-in tool rename.
902pub const READ_TOOLS: &[&str] = &["read_file"];
903
904/// One read-type tool result found in a message slice: the index of the
905/// `Role::Tool` result, the file path pulled from the paired assistant
906/// call's `path` argument (the `read_file` schema's one required field), and
907/// whether that call was a partial-window read (`offset` and/or `limit` set
908/// — `tools/builtins.rs`'s `ReadArgs`).
909#[derive(Debug, Clone)]
910struct DetectedRead {
911    index: usize,
912    path: PathBuf,
913    /// TR-3 v1 scope guard: `true` when the paired call set `offset` and/or
914    /// `limit` (a partial-window read, `tools/builtins.rs`'s `ReadArgs`).
915    /// TR-3.md's frozen spec excludes partial-window reads from v1 ("full-file
916    /// reads only... document the exclusion in the stub logic"): two windowed
917    /// reads of the same path may cover different line ranges entirely, so a
918    /// unified diff between them would present a diff between two arbitrary
919    /// windows as if it were a file change. Consulted ONLY by the TR-3
920    /// diffing candidate rule below — A8 elision and TR-2 dedup are
921    /// unaffected (A8 already fails closed on a windowed read via its
922    /// hash-mismatch fallback, `probe_read_freshness`'s doc comment above).
923    windowed: bool,
924}
925
926/// Find every read-type tool result in `msgs`: a `Role::Tool` message paired
927/// — by `tool_call_id` — to the nearest earlier assistant message whose
928/// `tool_calls` contains a matching id naming one of [`READ_TOOLS`], with a
929/// string `path` argument.
930///
931/// Pairing rides `tool_call_id` alone. Claude imports also stamp a
932/// `sourceToolAssistantUUID` metadata edge on the tool-result message
933/// (`session.rs:876-888`) parallel to `parentUuid`, but that id has no
934/// corresponding field recoverable on the assistant side through
935/// `ChatMessage`'s stable shape — and it doesn't need one here: Claude's own
936/// `tool_use_id` already becomes `tool_call_id` on import
937/// (`session.rs:876-881`), so `tool_call_id` pairing alone already covers
938/// both Codex and Claude Code sessions. `sourceToolAssistantUUID` is
939/// therefore not consulted (SPEC.md A8: "prefer the simple route").
940fn detect_reads(msgs: &[ChatMessage]) -> Vec<DetectedRead> {
941    let mut out = Vec::new();
942    for (i, msg) in msgs.iter().enumerate() {
943        if msg.role != Role::Tool {
944            continue;
945        }
946        let Some(call_id) = msg.tool_call_id.as_deref() else {
947            continue;
948        };
949        let call = msgs[..i].iter().rev().find_map(|m| {
950            if m.role != Role::Assistant {
951                return None;
952            }
953            m.tool_calls().iter().find(|c| c.id == call_id).cloned()
954        });
955        let Some(call) = call else {
956            continue;
957        };
958        if !READ_TOOLS.contains(&call.function.name.as_str()) {
959            continue;
960        }
961        let Ok(args) = call.function.parsed_arguments() else {
962            continue;
963        };
964        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
965            continue;
966        };
967        let windowed = args.get("offset").is_some_and(|v| !v.is_null())
968            || args.get("limit").is_some_and(|v| !v.is_null());
969        out.push(DetectedRead {
970            index: i,
971            path: PathBuf::from(path),
972            windowed,
973        });
974    }
975    out
976}
977
978/// Find every `Role::Tool` message in `msgs` whose paired assistant
979/// `tool_calls` entry names one of [`normalize::NORMALIZE_TOOLS`] — T30's
980/// candidate rule, keyed on tool IDENTITY rather than [`ChatMessage::name`]:
981/// a live [`crate::Agent`]'s own `history` populates `name` directly
982/// (`Agent::run_loop` builds tool results via
983/// `ChatMessage::tool_result(call.id, call.function.name, ...)`), but a
984/// session reloaded from an imported Claude Code or Codex log never does —
985/// `session.rs`'s `tool_message` helper always sets `name: None` there (the
986/// tool identity lives only on the paired assistant `tool_calls` entry in
987/// both wire formats). Same `tool_call_id` pairing [`detect_reads`] (A8)
988/// uses, and for the identical reason.
989fn detect_normalize_candidates(msgs: &[ChatMessage]) -> Vec<usize> {
990    let mut out = Vec::new();
991    for (i, msg) in msgs.iter().enumerate() {
992        if msg.role != Role::Tool {
993            continue;
994        }
995        let Some(call_id) = msg.tool_call_id.as_deref() else {
996            continue;
997        };
998        let named = msgs[..i].iter().rev().find_map(|m| {
999            if m.role != Role::Assistant {
1000                return None;
1001            }
1002            m.tool_calls()
1003                .iter()
1004                .find(|c| c.id == call_id)
1005                .map(|c| c.function.name.clone())
1006        });
1007        if named.is_some_and(|name| normalize::NORMALIZE_TOOLS.contains(&name.as_str())) {
1008            out.push(i);
1009        }
1010    }
1011    out
1012}
1013
1014/// One message index's freshness verdict from [`probe_read_freshness`].
1015#[derive(Debug, Clone, PartialEq, Eq)]
1016struct FreshEntry {
1017    fresh: bool,
1018    mtime: Option<i64>,
1019}
1020
1021/// The output of [`probe_read_freshness`] (A8): per-message-index freshness
1022/// verdicts, threaded into [`project_messages`] via
1023/// [`ReductionPolicy::read_freshness`]. Opaque on purpose — build it only
1024/// through `probe_read_freshness`; the empty [`Default`] means "nothing is
1025/// fresh," so a policy with `elide_stale_reads` set but no probe run against
1026/// it elides nothing (fails closed).
1027#[derive(Debug, Clone, Default, PartialEq, Eq)]
1028pub struct ReadFreshness {
1029    entries: HashMap<usize, FreshEntry>,
1030}
1031
1032/// The A8 disk-probe pre-pass, deliberately kept OUTSIDE [`project_messages`]
1033/// so the pure projection core never touches the filesystem itself (SPEC.md
1034/// A8's purity requirement). Call this with the exact same `msgs` slice about
1035/// to be projected — indices must line up — and thread the result through
1036/// [`ReductionPolicy::read_freshness`] before calling [`project_messages`] (or
1037/// [`project_messages`]); it is only ever consulted when `policy.elide_stale_reads` is
1038/// set, so callers that never enable A8 can skip calling this entirely.
1039///
1040/// **Staleness check.** For each `detect_reads` hit, this re-reads the file
1041/// and compares content_hash("hash of the current bytes, lossy-UTF8-decoded")
1042/// against the hash of the tool result's *recorded* content — exactly the
1043/// transform `read_file` itself applies for a plain whole-file read
1044/// (`tools/builtins.rs:70-97`, no `offset`/`limit`, file under
1045/// `MAX_READ_BYTES`). This one comparison also naturally covers the two
1046/// harder cases without duplicating `read_file`'s own decoration/slicing
1047/// logic (which lives in a file this change does not touch):
1048/// - a sliced read (`offset`/`limit` given): the recorded content is a line
1049///   slice, never byte-identical to a raw whole-file re-read, so the hash
1050///   mismatches and the read is (correctly, conservatively) never fresh;
1051/// - a read whose original result already carried `read_file`'s own
1052///   oversize-truncation notice: same reasoning, the recorded content is not
1053///   raw file bytes, so it never matches a raw re-read.
1054///
1055/// Both are the documented SPEC.md A8 fallback ("mtime+len only, document")
1056/// taken to its simplest safe form: this implementation's fallback for
1057/// anything it cannot cheaply verify is "treat as changed" (never elide),
1058/// which only ever under-elides, never over-elides — the safe direction.
1059///
1060/// Unreadable/deleted files are likewise never fresh. `mtime` is recorded for
1061/// [`ReadLogEntry::mtime`] whenever the file's metadata is readable, even
1062/// when the freshness verdict itself is `false`.
1063pub fn probe_read_freshness(msgs: &[ChatMessage]) -> ReadFreshness {
1064    let mut entries = HashMap::new();
1065    for d in detect_reads(msgs) {
1066        let recorded = msgs[d.index].content.as_deref().unwrap_or("");
1067        let mtime = std::fs::metadata(&d.path)
1068            .ok()
1069            .and_then(|m| m.modified().ok())
1070            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1071            .map(|dur| dur.as_secs() as i64);
1072        let fresh = match std::fs::read(&d.path) {
1073            Err(_) => false, // unreadable/deleted -> never elide
1074            Ok(bytes) => {
1075                let text = String::from_utf8_lossy(&bytes);
1076                content_hash(text.as_bytes()) == content_hash(recorded.as_bytes())
1077            }
1078        };
1079        entries.insert(d.index, FreshEntry { fresh, mtime });
1080    }
1081    ReadFreshness { entries }
1082}
1083
1084/// Populate the external disk-probe input required by A8 before projecting
1085/// `msgs`. All production projection/preflight sites route through this
1086/// helper so the context guard and the eventual provider request judge the
1087/// same stale-read savings. Other policy fields are untouched.
1088pub fn prepare_read_freshness(policy: &mut ReductionPolicy, msgs: &[ChatMessage]) {
1089    if policy.elide_stale_reads {
1090        policy.read_freshness = probe_read_freshness(msgs);
1091    }
1092}
1093
1094// ---------------------------------------------------------------------------
1095// A9 — image redaction: data: URL detection
1096// ---------------------------------------------------------------------------
1097
1098/// Parse a `data:` URL's declared media type: `data:<mediatype>[;base64],<data>`.
1099/// Returns `None` for anything not starting with the `data:` scheme (e.g. an
1100/// `https://…` image link, which A9 never touches — only inline base64
1101/// payloads are a redaction candidate). An empty or missing media type falls
1102/// back to `application/octet-stream` rather than failing the parse.
1103fn parse_data_url_mime(url: &str) -> Option<String> {
1104    let rest = url.strip_prefix("data:")?;
1105    let end = rest.find([';', ',']).unwrap_or(rest.len());
1106    let mime = &rest[..end];
1107    Some(if mime.is_empty() {
1108        "application/octet-stream".to_string()
1109    } else {
1110        mime.to_string()
1111    })
1112}
1113
1114/// One `image_url` content part found in `msgs` whose `url` is a `data:` URL —
1115/// a candidate for [`ReductionKind::ImageRedacted`] once compared against
1116/// [`ReductionPolicy::image_redact_min_bytes`]. Remote (`https://…`) image
1117/// URLs and non-image parts are never candidates.
1118#[derive(Debug, Clone)]
1119struct DetectedImage {
1120    msg_index: usize,
1121    part_index: usize,
1122    mime: String,
1123    url_len: usize,
1124}
1125
1126/// Find every `data:`-URL `image_url` content part in `msgs`. Already-redacted
1127/// parts are structurally excluded for free: [`project_messages`] replaces a
1128/// redacted part's JSON with a `{"type":"text", ...}` object, which this scan
1129/// no longer recognizes as an `image_url` part on a later re-projection — the
1130/// same reason prior reductions never need a separate "already reduced" guard
1131/// here the way A7/A8 do.
1132fn detect_images(msgs: &[ChatMessage]) -> Vec<DetectedImage> {
1133    let mut out = Vec::new();
1134    for (mi, msg) in msgs.iter().enumerate() {
1135        let Some(parts) = msg.content_parts.as_ref() else {
1136            continue;
1137        };
1138        for (pi, part) in parts.iter().enumerate() {
1139            if part.get("type").and_then(|t| t.as_str()) != Some("image_url") {
1140                continue;
1141            }
1142            let Some(url) = part
1143                .get("image_url")
1144                .and_then(|iu| iu.get("url"))
1145                .and_then(|u| u.as_str())
1146            else {
1147                continue;
1148            };
1149            let Some(mime) = parse_data_url_mime(url) else {
1150                continue; // not a data: URL -- e.g. a remote https:// link.
1151            };
1152            out.push(DetectedImage {
1153                msg_index: mi,
1154                part_index: pi,
1155                mime,
1156                url_len: url.len(),
1157            });
1158        }
1159    }
1160    out
1161}
1162
1163// ---------------------------------------------------------------------------
1164// TR-10 — ToolInputElided: assistant-side tool_use argument elision
1165// ---------------------------------------------------------------------------
1166
1167/// One structurally-eligible tool-input elision CANDIDATE found in `msgs`:
1168/// an assistant `tool_calls` entry naming a tool in `fields` (TR-10's
1169/// disk-persisted, write-family-by-default table), whose designated payload
1170/// field is present as a string, and whose paired tool result (matched by
1171/// `tool_call_id`, searched FORWARD from the assistant message — the
1172/// opposite direction from [`detect_reads`], which searches backward from a
1173/// tool result to its assistant call) exists and is
1174/// [`ToolOutcome::KnownSuccess`]. A call with no paired result yet (still
1175/// pending), an error, or an unknown result never appears here at all —
1176/// TR-10's success/failure boundary with TR-6 is enforced at DETECTION time,
1177/// never by a later filter.
1178#[derive(Debug, Clone)]
1179struct DetectedToolInput {
1180    msg_index: usize,
1181    call_id: String,
1182    tool_name: String,
1183    field: String,
1184    path: Option<PathBuf>,
1185    value: String,
1186}
1187
1188/// Find every structurally-eligible [`DetectedToolInput`] in `msgs`. Pure and
1189/// side-effect free — no size/protection/prior-reduction filtering happens
1190/// here (mirrors [`detect_reads`]'s split: detection is unconditional, the
1191/// caller in [`project_messages`] applies the size threshold and the
1192/// already-reduced/protected/cleared-range guards).
1193fn detect_tool_inputs(
1194    msgs: &[ChatMessage],
1195    fields: &HashMap<String, String>,
1196) -> Vec<DetectedToolInput> {
1197    let mut out = Vec::new();
1198    for (i, msg) in msgs.iter().enumerate() {
1199        if msg.role != Role::Assistant {
1200            continue;
1201        }
1202        for call in msg.tool_calls() {
1203            let Some(field) = fields.get(&call.function.name) else {
1204                continue;
1205            };
1206            let Ok(args) = call.function.parsed_arguments() else {
1207                continue;
1208            };
1209            let Some(value) = args.get(field.as_str()).and_then(|v| v.as_str()) else {
1210                continue;
1211            };
1212            let Some(result) = msgs[i + 1..].iter().find(|m| {
1213                m.role == Role::Tool && m.tool_call_id.as_deref() == Some(call.id.as_str())
1214            }) else {
1215                continue; // Still pending: never a candidate.
1216            };
1217            if tool_outcome(result) != ToolOutcome::KnownSuccess {
1218                continue; // Error or unknown: never eligible for TR-10.
1219            }
1220            // `path` (this crate's own `write_file`) and `file_path` (Claude
1221            // Code's native `Write`) are the two real-world spellings; a
1222            // sibling argument under neither name just means the stub's
1223            // `path` field stays `None` (never a hard failure).
1224            let path = args
1225                .get("path")
1226                .or_else(|| args.get("file_path"))
1227                .and_then(|v| v.as_str())
1228                .map(PathBuf::from);
1229            out.push(DetectedToolInput {
1230                msg_index: i,
1231                call_id: call.id.clone(),
1232                tool_name: call.function.name.clone(),
1233                field: field.clone(),
1234                path,
1235                value: value.to_string(),
1236            });
1237        }
1238    }
1239    out
1240}
1241
1242/// TR-6: the FAILURE-side complement of [`detect_tool_inputs`] — find every
1243/// structurally-eligible errored-call oversized-input candidate: an
1244/// assistant `tool_calls` entry naming a tool in `fields`, whose designated
1245/// payload field is present as a string, and whose paired tool result
1246/// (matched by `tool_call_id`, searched forward, identical pairing to
1247/// `detect_tool_inputs`) exists and is [`ToolOutcome::KnownError`]. A call
1248/// with no paired result yet, a known-success result, or an unknown result
1249/// never appears here at all: unknown Codex v1 outcomes fail closed on BOTH
1250/// reduction paths rather than being guessed from free-form text.
1251fn detect_errored_tool_inputs(
1252    msgs: &[ChatMessage],
1253    fields: &HashMap<String, String>,
1254) -> Vec<DetectedToolInput> {
1255    let mut out = Vec::new();
1256    for (i, msg) in msgs.iter().enumerate() {
1257        if msg.role != Role::Assistant {
1258            continue;
1259        }
1260        for call in msg.tool_calls() {
1261            let Some(field) = fields.get(&call.function.name) else {
1262                continue;
1263            };
1264            let Ok(args) = call.function.parsed_arguments() else {
1265                continue;
1266            };
1267            let Some(value) = args.get(field.as_str()).and_then(|v| v.as_str()) else {
1268                continue;
1269            };
1270            let Some(result) = msgs[i + 1..].iter().find(|m| {
1271                m.role == Role::Tool && m.tool_call_id.as_deref() == Some(call.id.as_str())
1272            }) else {
1273                continue; // Still pending: never a candidate (either side).
1274            };
1275            if tool_outcome(result) != ToolOutcome::KnownError {
1276                continue; // Success or unknown: never eligible for TR-6.
1277            }
1278            let path = args
1279                .get("path")
1280                .or_else(|| args.get("file_path"))
1281                .and_then(|v| v.as_str())
1282                .map(PathBuf::from);
1283            out.push(DetectedToolInput {
1284                msg_index: i,
1285                call_id: call.id.clone(),
1286                tool_name: call.function.name.clone(),
1287                field: field.clone(),
1288                path,
1289                value: value.to_string(),
1290            });
1291        }
1292    }
1293    out
1294}
1295
1296/// TR-6: the number of `Role::Assistant` messages appearing strictly after
1297/// `index` in `msgs` — the "N turns elapsed" aging clock for errored-input
1298/// pruning, a message-count proxy for "turns" (this codebase has no other
1299/// structural definition of a conversational turn; A10's own
1300/// `clear_turns_older_than` is likewise a message-count threshold, not a
1301/// literal turn counter).
1302fn assistant_turns_since(msgs: &[ChatMessage], index: usize) -> usize {
1303    msgs.get(index + 1..)
1304        .map(|rest| rest.iter().filter(|m| m.role == Role::Assistant).count())
1305        .unwrap_or(0)
1306}
1307
1308/// A UTF-8-safe ASCII-whitespace skip, byte-indexed — the primitive
1309/// [`find_top_level_string_field`]/[`skip_json_value`] share.
1310fn skip_ws(b: &[u8], mut i: usize) -> usize {
1311    while i < b.len() && b[i].is_ascii_whitespace() {
1312        i += 1;
1313    }
1314    i
1315}
1316
1317/// Parse one JSON string starting at `b[i] == '"'`. Returns
1318/// `(content_start, content_end, after)`: `content_start..content_end` bounds
1319/// the RAW (still `\`-escaped) string body (excluding the surrounding
1320/// quotes), and `after` is the index just past the closing quote. Byte-wise
1321/// scanning is UTF-8-safe here: JSON's only structural bytes inside a string
1322/// (`"` = 0x22, `\` = 0x5c) are ASCII values that can never appear as part of
1323/// a multi-byte UTF-8 continuation/lead byte (those are always >= 0x80), and
1324/// skipping exactly one byte after a `\` is always safe — every JSON escape
1325/// (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) has an
1326/// unambiguous, never-`"`-or-`\` byte immediately after the backslash.
1327fn parse_json_string(b: &[u8], i: usize) -> Option<(usize, usize, usize)> {
1328    if i >= b.len() || b[i] != b'"' {
1329        return None;
1330    }
1331    let content_start = i + 1;
1332    let mut j = content_start;
1333    while j < b.len() {
1334        match b[j] {
1335            b'\\' => j += 2,
1336            b'"' => return Some((content_start, j, j + 1)),
1337            _ => j += 1,
1338        }
1339    }
1340    None // Unterminated string.
1341}
1342
1343/// Skip over one arbitrary JSON value (string/object/array/number/bool/null)
1344/// starting at (possibly whitespace before) `b[i]`. Returns the index just
1345/// past it. Used by [`find_top_level_string_field`] to jump over sibling
1346/// fields it isn't looking for, however they're shaped, without needing to
1347/// interpret them.
1348fn skip_json_value(b: &[u8], i: usize) -> Option<usize> {
1349    let i = skip_ws(b, i);
1350    if i >= b.len() {
1351        return None;
1352    }
1353    match b[i] {
1354        b'"' => parse_json_string(b, i).map(|(_, _, end)| end),
1355        b'{' | b'[' => {
1356            let open = b[i];
1357            let close = if open == b'{' { b'}' } else { b']' };
1358            let mut depth = 0usize;
1359            let mut j = i;
1360            loop {
1361                if j >= b.len() {
1362                    return None;
1363                }
1364                match b[j] {
1365                    b'"' => {
1366                        let (_, _, end) = parse_json_string(b, j)?;
1367                        j = end;
1368                    }
1369                    c if c == open => {
1370                        depth += 1;
1371                        j += 1;
1372                    }
1373                    c if c == close => {
1374                        depth -= 1;
1375                        j += 1;
1376                        if depth == 0 {
1377                            return Some(j);
1378                        }
1379                    }
1380                    _ => j += 1,
1381                }
1382            }
1383        }
1384        _ => {
1385            // number / true / false / null: scan to the next structural byte.
1386            let mut j = i;
1387            while j < b.len() && !matches!(b[j], b',' | b'}' | b']') && !b[j].is_ascii_whitespace()
1388            {
1389                j += 1;
1390            }
1391            Some(j)
1392        }
1393    }
1394}
1395
1396/// Locate the byte span of the JSON STRING VALUE for top-level key `field`
1397/// within `json` — a serialized tool-call `arguments` string, assumed (like
1398/// every built-in write-family tool's flat schema) to be a JSON object.
1399/// Returns `(value_start, value_end)`: `json[value_start..value_end]` is the
1400/// RAW (still-escaped) string body, excluding the surrounding quotes — so a
1401/// caller can replace ONLY that span, leaving every other byte (key order,
1402/// whitespace, sibling fields, however shaped) untouched. Returns `None` when
1403/// `field` is absent, its value isn't a JSON string, or `json` isn't a
1404/// well-formed object — always a safe "don't touch it" signal, never a
1405/// guess.
1406fn find_top_level_string_field(json: &str, field: &str) -> Option<(usize, usize)> {
1407    let b = json.as_bytes();
1408    let mut i = skip_ws(b, 0);
1409    if i >= b.len() || b[i] != b'{' {
1410        return None;
1411    }
1412    i += 1;
1413    loop {
1414        i = skip_ws(b, i);
1415        if i >= b.len() {
1416            return None;
1417        }
1418        if b[i] == b'}' {
1419            return None; // Field not found.
1420        }
1421        let (key_start, key_end, after_key) = parse_json_string(b, i)?;
1422        let key = &json[key_start..key_end];
1423        i = skip_ws(b, after_key);
1424        if i >= b.len() || b[i] != b':' {
1425            return None;
1426        }
1427        i = skip_ws(b, i + 1);
1428        if i >= b.len() {
1429            return None;
1430        }
1431        if key == field {
1432            return if b[i] == b'"' {
1433                let (val_start, val_end, _) = parse_json_string(b, i)?;
1434                Some((val_start, val_end))
1435            } else {
1436                None // The field exists but isn't a string value.
1437            };
1438        }
1439        i = skip_json_value(b, i)?;
1440        i = skip_ws(b, i);
1441        match b.get(i) {
1442            Some(b',') => {
1443                i += 1;
1444                continue;
1445            }
1446            Some(b'}') => return None, // Reached the end without a match.
1447            _ => return None,          // Malformed / unexpected trailing bytes.
1448        }
1449    }
1450}
1451/// JSON-escape `s` for embedding as a string VALUE (no surrounding quotes) —
1452/// the content half of what `serde_json::to_string` would produce for it.
1453fn json_escape_content(s: &str) -> String {
1454    let quoted = serde_json::to_string(s).unwrap_or_default();
1455    let len = quoted.len();
1456    if len >= 2 {
1457        quoted[1..len - 1].to_string()
1458    } else {
1459        String::new()
1460    }
1461}
1462
1463/// Byte-surgical replacement of ONE top-level string field's value inside a
1464/// serialized JSON object: every other byte (key order, whitespace, sibling
1465/// fields) is preserved character-for-character (SPEC.md TR-10: "replace
1466/// only the payload field's value" — never a full reparse+reserialize, which
1467/// would reformat/reorder the rest of the arguments). Returns `None` (never
1468/// touching `json`) when `field` isn't present as a top-level string-valued
1469/// key.
1470fn replace_top_level_string_field(json: &str, field: &str, new_value: &str) -> Option<String> {
1471    let (start, end) = find_top_level_string_field(json, field)?;
1472    let mut out = String::with_capacity(json.len() + new_value.len());
1473    out.push_str(&json[..start]);
1474    out.push_str(&json_escape_content(new_value));
1475    out.push_str(&json[end..]);
1476    Some(out)
1477}
1478
1479/// Resolve and hash-verify the ORIGINAL value of a
1480/// [`ReductionKind::ToolInputElided`] reduction's payload field: locate the
1481/// addressed message, the tool_call within it named by `call_id`, extract
1482/// `field`'s value, and verify it against `ptr.content_hash` before ever
1483/// handing it back. Takes a message slice rather than a [`Session`] for the
1484/// same reason [`resolve_original_content`] does — both `invert`'s
1485/// sidecar-backed callers and [`rehydrate`]'s `minted_view`-backed caller
1486/// share this one resolver.
1487fn resolve_tool_input_value(
1488    ptr: &SidecarPtr,
1489    call_id: &str,
1490    field: &str,
1491    messages: &[ChatMessage],
1492) -> Result<String> {
1493    let msg = messages.get(ptr.addr.index).ok_or_else(|| {
1494        Error::new(format!(
1495            "invert: sidecar has no message at index {} (reduction pointer unresolvable)",
1496            ptr.addr.index
1497        ))
1498    })?;
1499    if msg.role != ptr.addr.role {
1500        return Err(Error::new(format!(
1501            "invert: role mismatch at sidecar index {}: pointer expects {:?}, sidecar has {:?}",
1502            ptr.addr.index, ptr.addr.role, msg.role
1503        )));
1504    }
1505    let call = msg
1506        .tool_calls()
1507        .iter()
1508        .find(|c| c.id == call_id)
1509        .ok_or_else(|| {
1510            Error::new(format!(
1511                "invert: sidecar message at index {} has no tool_call with id {call_id}",
1512                ptr.addr.index
1513            ))
1514        })?;
1515    let parsed = call.function.parsed_arguments().map_err(|e| {
1516        Error::new(format!(
1517            "invert: tool_call {call_id} arguments are not valid JSON: {e}"
1518        ))
1519    })?;
1520    let value = parsed
1521        .get(field)
1522        .and_then(|v| v.as_str())
1523        .ok_or_else(|| {
1524            Error::new(format!(
1525                "invert: tool_call {call_id} has no string field `{field}`"
1526            ))
1527        })?
1528        .to_string();
1529    ptr.verify(value.as_bytes())?;
1530    Ok(value)
1531}
1532
1533/// The decision a future escalation orchestrator (SPEC.md D13/wave-5
1534/// `escalate()` — not yet implemented in this codebase) should take for one
1535/// existing [`ReductionKind::ToolInputElided`] stub, per TR-10's freshness
1536/// matrix (reused from A8's `probe_read_freshness` pattern): does disk still
1537/// match what was written?
1538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1539pub enum EscalationAction {
1540    /// The stub is still faithful to disk — leave it as a stub (D13's
1541    /// "smallest faithful context", re-derivable at ~0 extra tokens).
1542    KeepStub,
1543    /// The stub is no longer faithful (disk has changed since, or is gone) —
1544    /// only the sidecar's recorded original is still faithful; rehydrate via
1545    /// [`invert_one_messages`].
1546    RehydrateFromSidecar,
1547}
1548
1549/// TR-10's freshness-matrix decision, given whether [`probe_tool_input_fresh`]
1550/// found the file still matching: `fresh` -> [`EscalationAction::KeepStub`],
1551/// `!fresh` -> [`EscalationAction::RehydrateFromSidecar`]. Split from the
1552/// probe itself (which does the actual disk I/O) so this half stays a pure,
1553/// trivially-testable function — the same purity discipline A8's
1554/// `probe_read_freshness`/`project_messages` split follows.
1555pub fn tool_input_escalation_action(fresh: bool) -> EscalationAction {
1556    if fresh {
1557        EscalationAction::KeepStub
1558    } else {
1559        EscalationAction::RehydrateFromSidecar
1560    }
1561}
1562
1563/// The A8-style disk probe behind [`tool_input_escalation_action`]: does the
1564/// file at `path` currently on disk still hash to `content_hash_hex`? Unlike
1565/// [`probe_read_freshness`] (which lossy-UTF8-decodes before hashing, to
1566/// mirror `read_file`'s own transform), this hashes the RAW bytes directly —
1567/// `write_file` writes `content.as_bytes()` with no transform, so the exact
1568/// bytes on disk are the fairer comparison. Unreadable/deleted files are
1569/// never fresh (fails closed, the same direction A8 fails in). A free
1570/// function (no `Reduction`/`ReductionLog` coupling) so it composes with
1571/// whatever wave-5 `escalate()` orchestration eventually calls it.
1572pub fn probe_tool_input_fresh(path: &std::path::Path, content_hash_hex: &str) -> bool {
1573    match std::fs::read(path) {
1574        Err(_) => false,
1575        Ok(bytes) => content_hash(&bytes) == content_hash_hex,
1576    }
1577}
1578
1579/// The largest `end <= target` such that `s.is_char_boundary(end)` — a
1580/// UTF-8-safe truncation point. Mirrors the boundary walk in
1581/// `agent.rs::cap_tool_output`.
1582fn char_boundary_floor(s: &str, target: usize) -> usize {
1583    let mut end = target.min(s.len());
1584    while end > 0 && !s.is_char_boundary(end) {
1585        end -= 1;
1586    }
1587    end
1588}
1589
1590/// Make an untrusted string fragment safe for interpolation into a stub
1591/// summary: [`stub::format`]'s grammar contract is "one line, no `]`", and a
1592/// tool name arrives verbatim from imported JSONL — attacker-shaped input. A
1593/// name containing `]` or a newline would trip `stub::format`'s
1594/// `debug_assert` (a panic in debug builds) and, in release builds, mint a
1595/// grammar-breaking stub that [`stub::parse`] rejects. Every `]` and every
1596/// control character is replaced with `_` — visible, honest damage instead
1597/// of a broken line.
1598fn sanitize_summary_fragment(s: &str) -> String {
1599    s.chars()
1600        .map(|c| if c == ']' || c.is_control() { '_' } else { c })
1601        .collect()
1602}
1603
1604/// Rebuild the exact reduced content for a [`ReductionKind::ToolOutputTruncated`]
1605/// reduction, given the *original* (unreduced) content at its target address:
1606/// the kept prefix, `"\n\n"`, then the recorded placeholder line — byte-for-byte
1607/// the same construction `project` used the first time it created `r`.
1608fn rebuild_truncated_content(original: &str, r: &Reduction) -> String {
1609    let kept = r.ptr.span.map(|(kept, _total)| kept).unwrap_or(0);
1610    let kept = kept.min(original.len());
1611    let mut s = original[..kept].to_string();
1612    s.push_str("\n\n");
1613    s.push_str(&r.placeholder);
1614    s
1615}
1616
1617/// Rebuild the exact reduced content for a [`ReductionKind::OutputNormalized`]
1618/// reduction, given the *original* (raw, unreduced) content at its target
1619/// address: [`normalize::normalize`] is a pure deterministic function, so
1620/// re-running it against the same original bytes reproduces byte-identical
1621/// normalized text every time; `"\n\n"` then the recorded placeholder
1622/// (carrying the honesty trailer) is appended exactly as `project_messages`
1623/// did the first time it created `r` — the same "recompute, don't store the
1624/// derived text" strategy [`rebuild_truncated_content`] uses for A7.
1625fn rebuild_normalized_content(original: &str, r: &Reduction) -> String {
1626    let mut s = normalize::normalize(original);
1627    s.push_str("\n\n");
1628    s.push_str(&r.placeholder);
1629    s
1630}
1631
1632/// Build the exact reduced content for a [`ReductionKind::FileReadDiffed`]
1633/// reduction: the stored placeholder line, a newline, then a freshly
1634/// recomputed unified diff of `base_text` (the base read's full content)
1635/// against `new_text` (this read's full content). Recomputing the diff
1636/// (rather than storing it) keeps `Reduction` itself small and — since
1637/// `diffy::create_patch` is a pure function of its two inputs — reproduces
1638/// byte-identically every time, the same prefix-stability guarantee
1639/// [`rebuild_truncated_content`] gives A7.
1640fn rebuild_diffed_content(base_text: &str, new_text: &str, r: &Reduction) -> String {
1641    let diff = diffy::create_patch(base_text, new_text);
1642    let mut s = r.placeholder.clone();
1643    s.push('\n');
1644    s.push_str(&diff.to_string());
1645    s
1646}
1647
1648/// Reapply one already-applied reduction from `prior` onto `view` in place,
1649/// exactly reproducing its placeholder — this is the prefix-stability
1650/// guarantee: an older reduction never churns between projections. `msgs` is
1651/// the pristine, never-mutated canonical slice `project_messages` was called
1652/// with — [`ReductionKind::FileReadDiffed`] resolves both its base and new
1653/// text from it (never from `view`), so a base that itself carries some
1654/// OTHER reduction in `view` (e.g. it was `FileReadElided` before later being
1655/// superseded as a diff base) never corrupts the recomputed diff.
1656///
1657/// [`ReductionKind::ToolOutputTruncated`], [`ReductionKind::FileReadElided`],
1658/// [`ReductionKind::ImageRedacted`], [`ReductionKind::OutputNormalized`],
1659/// [`ReductionKind::FileReadDiffed`], [`ReductionKind::DuplicateOutput`], and
1660/// [`ReductionKind::Superseded`] are all cardinality-preserving (content
1661/// mutated in place, `view`'s length and msgs-index alignment are untouched)
1662/// — `FileReadElided` simply
1663/// replaces the whole content with the stored placeholder verbatim
1664/// (whole-content elision, `ptr.span = None`), regardless of the file's
1665/// CURRENT on-disk state: prior-log stability means an already-elided read
1666/// stays elided even after the file changes again — a changed file only ever
1667/// blocks *new* elisions (SPEC.md A8), it never un-elides an existing one.
1668/// `ImageRedacted` likewise replaces the addressed content part with a
1669/// `{"type":"text", ...}` object carrying the stored placeholder verbatim,
1670/// regardless of the current part at that index. `OutputNormalized`
1671/// recomputes the normalized text from the *original* content at that index
1672/// via [`rebuild_normalized_content`] (pure/deterministic, so it reproduces
1673/// byte-identically). `DuplicateOutput` (TR-2) replaces the whole content
1674/// with the stored placeholder verbatim too (`ptr.span = None`, exactly like
1675/// `FileReadElided`) — irrespective of whether its `canonical` address is
1676/// still a plain message, itself now reduced, or has since been swallowed by
1677/// a `TurnsCleared` range: the duplicate's own placeholder never depends on
1678/// the canonical's current shape. [`ReductionKind::TurnsCleared`] (A10) is
1679/// NOT cardinality-preserving: it collapses a whole range `[first..=last]`
1680/// down to the single stored placeholder message via `Vec::splice`, so it
1681/// must be the LAST reapplication performed on `view` in any given
1682/// `project_messages` call (everything else addresses `view` by msgs-index,
1683/// which this invalidates for every index past `first`).
1684fn reapply_reduction(view: &mut Vec<ChatMessage>, r: &Reduction, msgs: &[ChatMessage]) {
1685    match &r.kind {
1686        ReductionKind::ToolOutputTruncated { .. } => {
1687            let idx = r.ptr.addr.index;
1688            let Some(msg) = view.get_mut(idx) else {
1689                return; // Addressed message no longer present; nothing to reapply.
1690            };
1691            if let Some(original) = msg.content.clone() {
1692                msg.content = Some(rebuild_truncated_content(&original, r));
1693            }
1694            set_reduction_id(msg, &r.id);
1695        }
1696        ReductionKind::OutputNormalized { .. } => {
1697            let idx = r.ptr.addr.index;
1698            let Some(msg) = view.get_mut(idx) else {
1699                return; // Addressed message no longer present; nothing to reapply.
1700            };
1701            if let Some(original) = msg.content.clone() {
1702                msg.content = Some(rebuild_normalized_content(&original, r));
1703            }
1704            set_reduction_id(msg, &r.id);
1705        }
1706        ReductionKind::FileReadElided { .. } => {
1707            let idx = r.ptr.addr.index;
1708            let Some(msg) = view.get_mut(idx) else {
1709                return; // Addressed message no longer present; nothing to reapply.
1710            };
1711            msg.content = Some(r.placeholder.clone());
1712            set_reduction_id(msg, &r.id);
1713        }
1714        ReductionKind::FileReadDiffed { base, .. } => {
1715            let idx = r.ptr.addr.index;
1716            if view.get(idx).is_none() {
1717                return; // Addressed message no longer present; nothing to reapply.
1718            }
1719            let (Some(base_text), Some(new_text)) = (
1720                msgs.get(base.index).and_then(|m| m.content.as_deref()),
1721                msgs.get(idx).and_then(|m| m.content.as_deref()),
1722            ) else {
1723                return; // Base or new content no longer resolvable against `msgs`.
1724            };
1725            let content = rebuild_diffed_content(base_text, new_text, r);
1726            let msg = &mut view[idx];
1727            msg.content = Some(content);
1728            set_reduction_id(msg, &r.id);
1729        }
1730        ReductionKind::TurnsCleared { first, last, .. } => {
1731            if *first > *last || *last >= view.len() {
1732                return; // Range no longer resolvable against this `view`; nothing to reapply.
1733            }
1734            let mut placeholder = ChatMessage::system(r.placeholder.clone());
1735            set_reduction_id(&mut placeholder, &r.id);
1736            view.splice(*first..=*last, std::iter::once(placeholder));
1737        }
1738        ReductionKind::ImageRedacted { part_index } => {
1739            let idx = r.ptr.addr.index;
1740            let Some(msg) = view.get_mut(idx) else {
1741                return; // Addressed message no longer present; nothing to reapply.
1742            };
1743            if let Some(parts) = msg.content_parts.as_mut() {
1744                if let Some(part) = parts.get_mut(*part_index) {
1745                    *part = serde_json::json!({"type": "text", "text": r.placeholder});
1746                }
1747            }
1748            set_reduction_id(msg, &r.id);
1749        }
1750        ReductionKind::ToolInputElided { call_id, field, .. } => {
1751            let idx = r.ptr.addr.index;
1752            // Compute the spliced arguments against the pristine `view[idx]`
1753            // (freshly derived from `msgs` at the top of `project_messages`,
1754            // so this always re-derives from the ORIGINAL arguments) before
1755            // taking a mutable borrow, mirroring the immutable-then-mutable
1756            // two-step `ImageRedacted` above uses.
1757            let Some(spliced) = view
1758                .get(idx)
1759                .and_then(|m| m.tool_calls.as_ref())
1760                .and_then(|calls| calls.iter().find(|c| &c.id == call_id))
1761                .and_then(|call| {
1762                    replace_top_level_string_field(&call.function.arguments, field, &r.placeholder)
1763                })
1764            else {
1765                return; // Addressed call no longer present, or reshaped; nothing to reapply.
1766            };
1767            let Some(msg) = view.get_mut(idx) else {
1768                return;
1769            };
1770            if let Some(calls) = msg.tool_calls.as_mut() {
1771                if let Some(call) = calls.iter_mut().find(|c| &c.id == call_id) {
1772                    call.function.arguments = spliced;
1773                }
1774            }
1775            set_reduction_id(msg, &r.id);
1776        }
1777        ReductionKind::DuplicateOutput { .. } => {
1778            let idx = r.ptr.addr.index;
1779            let Some(msg) = view.get_mut(idx) else {
1780                return; // Addressed message no longer present; nothing to reapply.
1781            };
1782            msg.content = Some(r.placeholder.clone());
1783            set_reduction_id(msg, &r.id);
1784        }
1785        ReductionKind::Superseded { .. } => {
1786            let idx = r.ptr.addr.index;
1787            let Some(msg) = view.get_mut(idx) else {
1788                return; // Addressed message no longer present; nothing to reapply.
1789            };
1790            msg.content = Some(r.placeholder.clone());
1791            set_reduction_id(msg, &r.id);
1792        }
1793    }
1794}
1795
1796/// Count how many of `msgs` have each of the three conversational roles
1797/// (user/assistant/tool) — used to build the A10 `turns-cleared` stub's
1798/// `(N messages: A user, B assistant, C tool)` summary clause.
1799fn count_roles(msgs: &[ChatMessage]) -> (usize, usize, usize) {
1800    let mut user = 0;
1801    let mut assistant = 0;
1802    let mut tool = 0;
1803    for m in msgs {
1804        match m.role {
1805            Role::User => user += 1,
1806            Role::Assistant => assistant += 1,
1807            Role::Tool => tool += 1,
1808            Role::System => {}
1809        }
1810    }
1811    (user, assistant, tool)
1812}
1813
1814/// The blake3 hash [`resolve_turns_range`] verifies for a [`ReductionKind::TurnsCleared`]
1815/// pointer — blake3 over each message's wire-serialized (`serde_json`) bytes
1816/// in `msgs`, concatenated in order — plus that concatenation's total byte
1817/// length (the creating side puts it in the stub summary so a reader can
1818/// judge the cleared range's size; the resolving side ignores it). Shared by
1819/// the creating side (here) and the resolving side (`resolve_turns_range`)
1820/// so the two hash formulas can never drift apart.
1821fn hash_turns_range(msgs: &[ChatMessage]) -> Result<(String, usize)> {
1822    let mut combined = Vec::new();
1823    for m in msgs {
1824        let bytes = serde_json::to_vec(m)
1825            .map_err(|e| Error::new(format!("failed to serialize message: {e}")))?;
1826        combined.extend_from_slice(&bytes);
1827    }
1828    Ok((content_hash(&combined), combined.len()))
1829}
1830
1831/// Pure function of `(msgs, policy, prior)`: the reduction engine's actual
1832/// body (SPEC.md A5, A7, A8, A9, A10, TR-2). A session adapter can delegate
1833/// its canonical message slice here; a runtime agent loop calls this
1834/// directly against `history[1..]` so a live agent can build the projected
1835/// request view without needing a `Session` wrapper around its own history.
1836///
1837/// Deterministic and side-effect free: identical inputs produce byte-identical
1838/// output; `msgs` is never mutated; nothing here touches the filesystem —
1839/// including for A8: `policy.read_freshness` is precomputed data, populated by
1840/// [`probe_read_freshness`] (the one place disk I/O happens) before this is
1841/// ever called. Every reduction already recorded in `prior` reproduces
1842/// verbatim (same id, same placeholder, byte-identical stub) — new reductions
1843/// only ever target messages older than the protected tail, so the reduced
1844/// prefix stays cache-stable across turns.
1845///
1846/// Pass order: TR-2 ([`ReductionKind::DuplicateOutput`], content-hash dedup)
1847/// runs FIRST, then TR-6 ([`ReductionKind::Superseded`], same-tool/
1848/// canonicalized-args keep-latest), then T30/TR-4
1849/// ([`ReductionKind::OutputNormalized`], ANSI/redraw collapse), then
1850/// [`ReductionKind::ToolOutputTruncated`] (A7), then the read-family passes —
1851/// [`ReductionKind::FileReadElided`] (A8) for an unchanged re-read,
1852/// [`ReductionKind::FileReadDiffed`] (TR-3) for a changed one — then
1853/// [`ReductionKind::ImageRedacted`] (A9), then TR-10
1854/// ([`ReductionKind::ToolInputElided`], both the successful-call case and
1855/// TR-6's failed-call errored-input-pruning complement), then
1856/// [`ReductionKind::TurnsCleared`] (A10) last (the one cardinality-changing
1857/// pass). Each of TR-2, TR-6, T30/TR-4, and A7 claims a message's index in
1858/// `reduced_this_run` the moment it mints a reduction for it, and every pass
1859/// after the first checks that set — so a single message is claimed by
1860/// exactly one pass per `project_messages` call, never two.
1861///
1862/// TR-2 running before TR-6, T30/TR-4, and A7 means a byte-identical
1863/// duplicate is deduped — the cheapest of the four reductions — rather than
1864/// independently superseded, normalized, or truncated. TR-6 running before
1865/// T30/TR-4 and A7 follows the identical reasoning one step further: a
1866/// result about to be superseded down to one small stub never needs
1867/// normalizing or truncating first either. See the TR-2 and TR-6 passes
1868/// below for why this ordering must hold within a single call, not just
1869/// "eventually" (in short: both verdicts are pure functions of `msgs`, so
1870/// they are unaffected by running before or after T30/TR-4, but T30/TR-4 and
1871/// A7 both read from `view`, which TR-2/TR-6 may have already stubbed — so
1872/// TR-2 then TR-6 must go first, or their own claims could lose a race to a
1873/// pass that mutates `view` ahead of them).
1874/// T30/TR-4 running before A7 means a noisy bash/exec output is collapsed to
1875/// its final rendered content BEFORE A7 ever measures it against
1876/// `tool_output_trigger_bytes` — but T30/TR-4 only CLAIMS the message (taking
1877/// it out of A7's candidate pool) when its own normalized rendering already
1878/// fits under `tool_output_trigger_bytes`; that rendering is what rides the
1879/// wire, already bounded, so no truncation is needed on top of it. When the
1880/// normalized rendering is STILL over the trigger — genuinely large, mostly
1881/// distinct content, not just redraw noise — T30/TR-4 deliberately does not
1882/// claim the message at all (raw, untouched) and lets it fall through to A7
1883/// below, which truncates the RAW bytes to `tool_output_keep_bytes`. Either
1884/// way the wire payload for a terminal output is bounded by A7's trigger —
1885/// the P7 runaway-output safety net (SPEC.md/TR-12) is preserved for BOTH
1886/// small-after-normalization and large-after-normalization outputs. TR-2 and
1887/// TR-6 both never claim a read-type tool result (`detect_reads`), even a
1888/// byte-identical or same-args re-read: the read-family passes own that
1889/// address space exclusively, with strictly more information (path-aware
1890/// freshness, a unified diff) than either TR-2's flat "identical to msg #N"
1891/// or TR-6's flat "superseded by msg #N" stub could express; T30/TR-4 is
1892/// likewise scoped to [`normalize::NORMALIZE_TOOLS`] tool identities,
1893/// disjoint from `READ_TOOLS`, so it never contends with the read-family
1894/// passes over the same index either.
1895pub fn project_messages(
1896    msgs: &[ChatMessage],
1897    policy: &ReductionPolicy,
1898    prior: &ReductionLog,
1899) -> (Vec<ChatMessage>, ReductionLog) {
1900    let mut view: Vec<ChatMessage> = msgs.to_vec();
1901    let mut log = prior.clone();
1902
1903    // Reproduce every already-applied ToolOutputTruncated (etc.) reduction
1904    // verbatim first: cardinality-preserving, so `view` stays index-parallel
1905    // to `msgs` while this runs. TurnsCleared (cardinality-changing) is
1906    // handled last, below, once every msgs-indexed operation is done.
1907    for r in &prior.reductions {
1908        if !matches!(r.kind, ReductionKind::TurnsCleared { .. }) {
1909            reapply_reduction(&mut view, r, msgs);
1910        }
1911    }
1912
1913    // A10: old-turn clearing is a one-time context edit for the AUTO-
1914    // COMPACTOR (`Agent::maybe_compact`'s `clear_turns_older_than`), not a
1915    // repeating compaction — once ANY `TurnsCleared` record exists, the
1916    // auto-compactor never mints a new one (below). But a session can carry
1917    // MORE than one `TurnsCleared` record: TR-9 (T24) `handoff` establishes a
1918    // whole set of disjoint spanning clears in one shot (a handoff keep-set
1919    // is generally scattered — system prompt + some named early turns + last
1920    // K — so the non-kept middle forms several contiguous gaps, one
1921    // `TurnsCleared` per gap). So this collects EVERY existing record rather
1922    // than just the first found; each one, once established, is reapplied
1923    // verbatim forever (never widened, never recomputed), so no placeholder
1924    // ever churns between turns.
1925    let mut existing_clears: Vec<(usize, usize)> = prior
1926        .reductions
1927        .iter()
1928        .chain(prior.expanded.iter())
1929        .filter_map(|r| match r.kind {
1930            ReductionKind::TurnsCleared { first, last, .. } => Some((first, last)),
1931            _ => None,
1932        })
1933        .collect();
1934    existing_clears.sort_by_key(|&(f, _)| f);
1935    let in_existing_clear = |i: usize| existing_clears.iter().any(|&(f, l)| i >= f && i <= l);
1936
1937    let already_reduced: HashSet<usize> = prior
1938        .reductions
1939        .iter()
1940        .chain(prior.expanded.iter())
1941        .filter(|r| !matches!(r.kind, ReductionKind::TurnsCleared { .. }))
1942        .map(|r| r.ptr.addr.index)
1943        .collect();
1944
1945    // Protect the newest `protect_last_n_tool_results` tool results from ever
1946    // becoming a *new* candidate (prior reductions on now-recent messages are
1947    // left as-is above — stability wins over re-protecting them).
1948    let protected: HashSet<usize> = view
1949        .iter()
1950        .enumerate()
1951        .filter(|(_, m)| m.role == Role::Tool)
1952        .map(|(i, _)| i)
1953        .rev()
1954        .take(policy.protect_last_n_tool_results)
1955        .collect();
1956
1957    // Indices reduced (of any kind) during THIS call — as opposed to
1958    // `already_reduced` (reduced in a PRIOR call). A message can only ever
1959    // carry one reduction kind at a time, so every pass below must skip
1960    // anything an EARLIER pass this run just claimed, in addition to
1961    // everything `already_reduced`/`protected`/`in_existing_clear` already
1962    // exclude. Declared before TR-2 (the first pass to populate it) rather
1963    // than before A7, so A7's own candidate filter can already respect it.
1964    // `ordinal` is likewise shared by every pass below and only ever consumed
1965    // on an actual mint (a `continue`d candidate never advances it), so ids
1966    // stay dense and deterministic regardless of which passes end up firing.
1967    let mut reduced_this_run: HashSet<usize> = HashSet::new();
1968
1969    // Logs can be sparse after an older client expanded a record by simply
1970    // removing it. Counting records can therefore reuse a still-live
1971    // ordinal (and, with the same hash prefix, the exact same id). Always
1972    // advance past the greatest parseable persisted ordinal across both
1973    // active and explicitly-expanded records.
1974    let mut ordinal = next_reduction_ordinal(
1975        prior
1976            .reductions
1977            .iter()
1978            .chain(prior.expanded.iter())
1979            .map(|r| r.id.as_str()),
1980    );
1981
1982    // ---- TR-2: DuplicateOutput -- content-hash dedup of identical tool
1983    // outputs ----
1984    //
1985    // Runs BEFORE both T30/TR-4 (OutputNormalized, immediately below) and A7:
1986    // deduping a later duplicate is strictly cheaper than either
1987    // independently normalizing or truncating it, and — more importantly —
1988    // it must claim a duplicate's index in `reduced_this_run` before either
1989    // of those passes' own candidate scans run, or they would claim it first
1990    // and that claim would stick forever (prefix stability: `already_reduced`
1991    // never lets a later run downgrade an existing reduction to a cheaper
1992    // kind). Hash comparisons are taken from `msgs` (the ORIGINAL,
1993    // never-mutated slice) rather than `view`, exactly like A10's
1994    // `hash_turns_range` below — so the canonical bytes compared are always
1995    // the true original content, an already-stubbed earlier occurrence's
1996    // placeholder text is never what gets hashed. This also makes TR-2's own
1997    // verdict completely insensitive to relative pass order with
1998    // OutputNormalized: a terminal output that is BOTH a normalize candidate
1999    // (raw ANSI/CR noise) AND byte-identical to an earlier tool result is
2000    // claimed HERE by TR-2 — a `DuplicateOutput` stub is typically far
2001    // smaller than even a normalized rendering — and OutputNormalized's own
2002    // candidate filter (below) skips it via `reduced_this_run`. Neither pass
2003    // ever touches the stored sidecar bytes (both keep the RAW capture
2004    // there, A3), so this precedence is purely about which single stub wins
2005    // the VIEW, never about invert-to-raw correctness for either kind.
2006    //
2007    // The first (chronologically earliest) still-addressable occurrence of a
2008    // content hash is the canonical; every later occurrence with the same
2009    // hash becomes a `DuplicateOutput` candidate, provided it isn't already
2010    // reduced, protected, or about to be swallowed by an existing
2011    // `TurnsCleared` range. `SidecarPtr::addr` on the minted reduction is the
2012    // DUPLICATE's own address (not the canonical's) — same self-addressing
2013    // convention as every other kind — so `invert`/`expand_reduction` always
2014    // resolve it independent of whatever later happens to the canonical's
2015    // own slot (dev/04: the canonical may itself be truncated or cleared in
2016    // a later run without ever affecting this pointer).
2017    //
2018    // ONLY same-path re-reads (`detect_reads` hits whose path already
2019    // appeared in an EARLIER detected read this slice) are excluded from
2020    // this pass entirely — canonical registration AND duplicate candidacy —
2021    // and left for the A8/TR-3 pass below (post-merge reconciliation: TR-2
2022    // landed generalizing A8's OWN prior dedup special-case to "any tool
2023    // output", but a re-read of a file already carries a strictly richer,
2024    // path-aware redundancy mechanism there — freshness-gated elision for an
2025    // unchanged re-read, a unified diff for a changed one — that TR-2's flat
2026    // "identical to msg #N" stub would otherwise pre-empt whenever a re-read
2027    // happens to be byte-identical to its own prior read, silently losing
2028    // the elision-vs-diff distinction SPEC.md TR-3 dev/05 requires).
2029    //
2030    // Deliberately narrower than "every detected read": the FIRST read of a
2031    // given path has no earlier same-path read for the A8/TR-3 pass to
2032    // diff/elide against, so it is not that pass's address space at all —
2033    // TR-2 must stay free to dedup it against a byte-identical output
2034    // anywhere else in the slice (a different path's read, or any other
2035    // tool result), exactly as it would for any other tool. Excluding every
2036    // read unconditionally (the pre-fix behavior) silently disabled TR-2 for
2037    // two content-identical reads of DIFFERENT paths, which A8/TR-3's
2038    // path-keyed matching never claims and never will.
2039    let read_indices: HashSet<usize> = {
2040        let detected = detect_reads(msgs);
2041        let mut seen_paths: HashSet<&std::path::Path> = HashSet::new();
2042        detected
2043            .iter()
2044            .filter(|d| !seen_paths.insert(d.path.as_path()))
2045            .map(|d| d.index)
2046            .collect()
2047    };
2048    let mut first_seen: HashMap<String, usize> = HashMap::new();
2049    for (i, m) in msgs.iter().enumerate() {
2050        if !policy.deduplicate_outputs || m.role != Role::Tool || read_indices.contains(&i) {
2051            continue;
2052        }
2053        let Some(content) = m.content.as_ref() else {
2054            continue;
2055        };
2056        if content.len() < policy.duplicate_output_min_bytes {
2057            // Below the savings floor on EITHER side of an identical pair
2058            // (same hash implies same length): never a dedup candidate,
2059            // canonical or duplicate.
2060            continue;
2061        }
2062        let hash = content_hash(content.as_bytes());
2063        let Some(&canonical_idx) = first_seen.get(&hash) else {
2064            // First occurrence of this hash: a candidate canonical, unless it
2065            // is not genuinely "still visible in the view" as ITS OWN full
2066            // content right now — either already reduced (of any kind, from
2067            // a PRIOR run: its slot shows a stub, not the bytes a new
2068            // duplicate should be judged identical-and-visible against) or
2069            // about to vanish into an existing `TurnsCleared` range. In
2070            // either case leave it unrecorded so the NEXT still-fully-visible
2071            // occurrence becomes canonical instead (or, if there is none,
2072            // this hash simply never gets deduped this run — never a
2073            // correctness issue, only a missed savings opportunity, and
2074            // exactly what keeps this pass from re-litigating an A8/A7
2075            // decision a prior run already made).
2076            if !already_reduced.contains(&i) && !in_existing_clear(i) {
2077                first_seen.insert(hash, i);
2078            }
2079            continue;
2080        };
2081        if already_reduced.contains(&i) || protected.contains(&i) || in_existing_clear(i) {
2082            continue;
2083        }
2084
2085        let original_bytes = content.len();
2086        let id = make_id(ordinal, &hash);
2087        let tool_name = sanitize_summary_fragment(m.name.as_deref().unwrap_or("tool"));
2088        let summary = format!(
2089            "{tool_name} output duplicates msg #{canonical_idx} ({}B) — identical to an \
2090             earlier tool result, full output in session sidecar",
2091            format_commas(original_bytes),
2092        );
2093        let placeholder = stub::format(stub::Kind::Duplicate, &id, &summary);
2094        // Structural negative-savings guard: the savings floor
2095        // (`duplicate_output_min_bytes`) is a heuristic, not a guarantee —
2096        // the tool name is interpolated into the summary, so a long
2097        // (untrusted, imported) name can push the stub past the size of the
2098        // very content it replaces. Never mint a reduction that costs more
2099        // than it saves. `ordinal` is only consumed on an actual mint, so a
2100        // skip here is invisible to later ids (deterministic either way).
2101        if placeholder.len() >= original_bytes {
2102            continue;
2103        }
2104        ordinal += 1;
2105
2106        let reduction = Reduction {
2107            id: id.clone(),
2108            kind: ReductionKind::DuplicateOutput {
2109                canonical: MessageAddr {
2110                    index: canonical_idx,
2111                    role: Role::Tool,
2112                },
2113                original_bytes,
2114            },
2115            ptr: SidecarPtr {
2116                addr: MessageAddr {
2117                    index: i,
2118                    role: Role::Tool,
2119                },
2120                span: None,
2121                content_hash: hash,
2122            },
2123            placeholder,
2124        };
2125
2126        view[i].content = Some(reduction.placeholder.clone());
2127        set_reduction_id(&mut view[i], &reduction.id);
2128
2129        reduced_this_run.insert(i);
2130        log.reductions.push(reduction);
2131    }
2132
2133    // ---- TR-6: Superseded — keep-latest for same tool + canonicalized args ----
2134    //
2135    // Runs immediately after TR-2's dedup pass (immediately above) and BEFORE
2136    // every other pass (T30/TR-4, A7, the read family, A9, TR-10, A10) — the
2137    // TR-6.md frozen ordering ("run after TR-2 dedup ... before A7/A10").
2138    // Placing it ahead of T30/TR-4 too follows the exact same reasoning TR-2
2139    // itself is placed ahead of T30/TR-4 for: superseding a whole message
2140    // down to one small stub is strictly cheaper than independently
2141    // normalizing or truncating content that is about to be evicted anyway,
2142    // and it must claim its candidates' indices in `reduced_this_run` before
2143    // any later pass' own candidate scan runs, or that pass would claim them
2144    // first and the claim would stick forever (prefix stability).
2145    //
2146    // Scoped to the exact same address space TR-2 claims from (`Role::Tool`
2147    // results, excluding `read_indices` — the read-family passes, A8/TR-3,
2148    // own re-reads exclusively, with strictly richer path-aware redundancy
2149    // handling than a flat same-key stub could express). Two occurrences
2150    // sharing a `supersede::canonical_key` are NOT required to be
2151    // byte-identical (unlike TR-2) — an old FAILING `cargo test` run and a
2152    // later PASSING run of the identical command are exactly the case this
2153    // exists for; a byte-identical pair is TR-2's exclusive territory
2154    // (`recurring_hashes`, below) — this pass never mints a `Superseded` for
2155    // content whose hash recurs anywhere else, so it never re-litigates
2156    // TR-2's own decision (see `recurring_hashes`'s doc comment for why this
2157    // must be an explicit content-hash check, not just "TR-2 runs first").
2158    if policy.supersede_enabled {
2159        let supersede_protected: HashSet<usize> = view
2160            .iter()
2161            .enumerate()
2162            .filter(|(_, m)| m.role == Role::Tool)
2163            .map(|(i, _)| i)
2164            .rev()
2165            .take(policy.supersede_protect_last_n)
2166            .collect();
2167
2168        let occurrences = supersede::detect(msgs, &read_indices, &policy.supersede_command_fields);
2169        let mut by_key: HashMap<&str, Vec<usize>> = HashMap::new();
2170        for c in &occurrences {
2171            by_key.entry(c.key.as_str()).or_default().push(c.index);
2172        }
2173
2174        // Content whose hash recurs ANYWHERE among (non-read) tool results is
2175        // TR-2's exclusive territory, full stop — never a Superseded
2176        // candidate, regardless of the two passes' relative protection-zone
2177        // timing. This is not just "TR-2 runs first within one call": TR-2's
2178        // OWN candidate rule only ever mints a duplicate once TWO
2179        // occurrences of the same hash are SIMULTANEOUSLY unprotected in the
2180        // same `project_messages` call (its `first_seen` canonical stays
2181        // unrecorded, and unreduced, until then) — a live agent calls
2182        // `project_messages` incrementally, once per turn, so an occurrence
2183        // can age out of `protect_last_n_tool_results` SOLO, one turn before
2184        // any later identical occurrence does too. Without this guard,
2185        // Superseded's own (intentionally less choosy — it needs no partner,
2186        // just "not the newest") candidate rule would win that race and
2187        // permanently evict the EARLIEST copy of a byte-identical run — the
2188        // exact copy TR-2 means to keep forever as its canonical. Computed
2189        // fresh here (not reused from TR-2's own local `first_seen`, which
2190        // only ever covers hashes TR-2 itself has already deemed candidate-
2191        // eligible at ITS point in time, not "every hash that recurs").
2192        let mut recurring_hashes: HashSet<String> = HashSet::new();
2193        let mut hash_occurrences: HashMap<String, Vec<usize>> = HashMap::new();
2194        for (i, m) in msgs.iter().enumerate() {
2195            if m.role != Role::Tool || read_indices.contains(&i) {
2196                continue;
2197            }
2198            let Some(content) = m.content.as_ref() else {
2199                continue;
2200            };
2201            let h = content_hash(content.as_bytes());
2202            let occurrences = hash_occurrences.entry(h.clone()).or_default();
2203            occurrences.push(i);
2204            if occurrences.len() > 1 {
2205                recurring_hashes.insert(h);
2206            }
2207        }
2208
2209        // All but the newest (highest-index) occurrence of each key are
2210        // candidates; every candidate names the NEWEST occurrence as its
2211        // successor (SPEC.md TR-6 dev/01: "first three ... naming the 4th",
2212        // not each other's immediate successor). Gathered into one flat list
2213        // and sorted by index for deterministic minting order, mirroring
2214        // every other pass's tie-break rule.
2215        let mut mint_candidates: Vec<(usize, usize)> = Vec::new(); // (index, successor_index)
2216        for indices in by_key.values() {
2217            if indices.len() < 2 {
2218                continue; // A lone occurrence of a key has nothing to supersede it.
2219            }
2220            let mut sorted = indices.clone();
2221            sorted.sort_unstable();
2222            let newest = *sorted.last().expect("checked len >= 2 above");
2223            for &idx in &sorted[..sorted.len() - 1] {
2224                mint_candidates.push((idx, newest));
2225            }
2226        }
2227        mint_candidates.sort_by_key(|&(idx, _)| idx);
2228
2229        for (idx, successor_idx) in mint_candidates {
2230            if already_reduced.contains(&idx)
2231                || reduced_this_run.contains(&idx)
2232                || supersede_protected.contains(&idx)
2233                || in_existing_clear(idx)
2234            {
2235                continue;
2236            }
2237            let original = view[idx].content.clone().unwrap_or_default();
2238            let original_bytes = original.len();
2239            if original_bytes < policy.supersede_min_bytes {
2240                continue; // Below the savings floor: never a candidate.
2241            }
2242            let hash = content_hash(original.as_bytes());
2243            if recurring_hashes.contains(&hash) {
2244                // Byte-identical elsewhere: TR-2's territory — with one
2245                // carve-out (TR-15). The guard exists because an occurrence
2246                // can age out of protection SOLO, a turn before its identical
2247                // twin does, and superseding it then would permanently evict
2248                // the earliest copy TR-2 means to keep visible as its dedup
2249                // canonical. Once every OTHER occurrence of this hash is
2250                // already reduced (TR-2 has paired them all, this run or
2251                // earlier), that race no longer exists: this candidate is the
2252                // group's dedup ANCHOR, its `DuplicateOutput` children
2253                // resolve self-addressed from the sidecar (`canonical` is
2254                // display-only), and a genuinely newer DISTINCT result
2255                // supersedes the whole group — so the anchor is stubbed too
2256                // and only the successor stays full. A successor that is
2257                // itself byte-identical to this candidate (same hash) is no
2258                // "newer result" at all — the group would supersede itself —
2259                // so the guard holds unconditionally there.
2260                let others_all_reduced = hash_occurrences.get(&hash).is_some_and(|occurrences| {
2261                    occurrences.iter().all(|&j| {
2262                        j == idx || already_reduced.contains(&j) || reduced_this_run.contains(&j)
2263                    })
2264                });
2265                let successor_is_distinct = msgs
2266                    .get(successor_idx)
2267                    .and_then(|m| m.content.as_ref())
2268                    .is_some_and(|content| content_hash(content.as_bytes()) != hash);
2269                if !(others_all_reduced && successor_is_distinct) {
2270                    continue;
2271                }
2272            }
2273            let id = make_id(ordinal, &hash);
2274            let tool_name = sanitize_summary_fragment(
2275                occurrences
2276                    .iter()
2277                    .find(|c| c.index == idx)
2278                    .map(|c| c.tool_name.as_str())
2279                    .unwrap_or("tool"),
2280            );
2281            let summary = format!(
2282                "{tool_name} superseded by newer result at msg #{successor_idx} ({}B) — \
2283                 expand_reduction(\"{id}\") to restore",
2284                format_commas(original_bytes),
2285            );
2286            let placeholder = stub::format(stub::Kind::Superseded, &id, &summary);
2287            // Structural negative-savings guard, mirrors TR-2's own: never
2288            // mint a reduction that costs more than it saves (a long,
2289            // untrusted tool name interpolated into the summary can in
2290            // principle push the stub past the content it replaces).
2291            if placeholder.len() >= original_bytes {
2292                continue;
2293            }
2294            ordinal += 1;
2295
2296            let reduction = Reduction {
2297                id: id.clone(),
2298                kind: ReductionKind::Superseded {
2299                    by: MessageAddr {
2300                        index: successor_idx,
2301                        role: Role::Tool,
2302                    },
2303                    original_bytes,
2304                },
2305                ptr: SidecarPtr {
2306                    addr: MessageAddr {
2307                        index: idx,
2308                        role: Role::Tool,
2309                    },
2310                    span: None,
2311                    content_hash: hash,
2312                },
2313                placeholder,
2314            };
2315
2316            view[idx].content = Some(reduction.placeholder.clone());
2317            set_reduction_id(&mut view[idx], &reduction.id);
2318
2319            reduced_this_run.insert(idx);
2320            log.reductions.push(reduction);
2321        }
2322    }
2323
2324    // ---- T30/TR-4: OutputNormalized — terminal-noise normalization ----
2325    //
2326    // Runs AFTER TR-2's dedup pass (immediately above — see its comment for
2327    // the dedup/normalize precedence rule) but BEFORE A7's truncation
2328    // candidates are even computed: normalizing a noisy bash/exec output
2329    // BEFORE truncating it means that, when the normalized rendering already
2330    // fits under `tool_output_trigger_bytes`, it is claimed HERE (collapsed,
2331    // no ANSI/redraw garbage, already bounded) and needs no truncation at
2332    // all. When the normalized rendering is STILL over the trigger — real,
2333    // mostly-distinct content, not redraw noise — this pass does NOT claim
2334    // the message (see the `normalized_bytes > policy.tool_output_trigger_bytes`
2335    // check below); it is left raw for A7's candidate scan to pick up and
2336    // truncate, so the wire payload for every terminal output stays bounded
2337    // by the trigger either way (the P7 runaway-output safety net).
2338    // Deliberately does NOT check `protected`: unlike A7's truncation (which discards
2339    // real content the model might need next turn), collapsing redraws is
2340    // content-lossless — the rendered text a recent tool result carries is
2341    // fully preserved, only presentation bytes are removed, so there is no
2342    // "protect the recent tail" reason to skip it (matching A9's
2343    // `ImageRedacted`, which likewise never consults `protected`). DOES
2344    // check `reduced_this_run` (as well as `already_reduced`), so a message
2345    // TR-2 just claimed above is never also claimed here — each message is
2346    // claimed by exactly one pass per run.
2347    if policy.normalize_terminal_output {
2348        let candidates: Vec<usize> = detect_normalize_candidates(&view)
2349            .into_iter()
2350            .filter(|i| {
2351                !already_reduced.contains(i)
2352                    && !reduced_this_run.contains(i)
2353                    && !in_existing_clear(*i)
2354            })
2355            .collect();
2356
2357        for idx in candidates {
2358            let original = view[idx].content.clone().unwrap_or_default();
2359            let original_bytes = original.len();
2360            let normalized = normalize::normalize(&original);
2361            let normalized_bytes = normalized.len();
2362            if original_bytes.saturating_sub(normalized_bytes) < policy.terminal_output_min_savings
2363            {
2364                continue; // Below the savings floor: leave untouched.
2365            }
2366            if normalized_bytes > policy.tool_output_trigger_bytes {
2367                // P7 safety net (SPEC.md/TR-12): the wire payload for ANY
2368                // terminal output must stay bounded by A7's trigger. Most
2369                // ANSI/redraw noise collapses to far less than the trigger,
2370                // but when the underlying content is genuinely large and
2371                // mostly distinct (not just redraw noise), the normalized
2372                // rendering can still exceed it — claiming the message here
2373                // would let that uncapped view ride the wire unbounded,
2374                // reintroducing the runaway-output incident A7 exists to
2375                // prevent. Leave it unclaimed (raw, untouched, `ordinal` not
2376                // consumed) so it falls through to A7 below, which truncates
2377                // the RAW bytes to `tool_output_keep_bytes`, bounded and
2378                // reversible exactly as it was pre-TR-4. Only a normalized
2379                // rendering that already fits under the trigger is claimed
2380                // here, as a collapsed-and-already-bounded view.
2381                continue;
2382            }
2383
2384            let hash = content_hash(original.as_bytes());
2385            let id = make_id(ordinal, &hash);
2386            ordinal += 1;
2387
2388            let summary = normalize::summary(original_bytes, normalized_bytes);
2389            let placeholder = stub::format(stub::Kind::OutputNormalized, &id, &summary);
2390
2391            let reduction = Reduction {
2392                id: id.clone(),
2393                kind: ReductionKind::OutputNormalized {
2394                    original_bytes,
2395                    normalized_bytes,
2396                },
2397                ptr: SidecarPtr {
2398                    addr: MessageAddr {
2399                        index: idx,
2400                        role: view[idx].role,
2401                    },
2402                    span: None,
2403                    content_hash: hash,
2404                },
2405                placeholder,
2406            };
2407
2408            let mut new_content = normalized;
2409            new_content.push_str("\n\n");
2410            new_content.push_str(&reduction.placeholder);
2411            view[idx].content = Some(new_content);
2412            set_reduction_id(&mut view[idx], &reduction.id);
2413
2414            reduced_this_run.insert(idx);
2415            log.reductions.push(reduction);
2416        }
2417    }
2418
2419    // ---- A8/TR-3: FileReadElided / FileReadDiffed — re-read handling ----
2420    //
2421    // Runs BEFORE A7 (TR-14): a re-read is diff-collapsed or elided here —
2422    // often shrinking it well under A7's trigger — instead of being blunt
2423    // prefix-truncated by A7's oversized-output scan, which has no read-type
2424    // exclusion of its own. TR-2 still runs first (byte-identical outputs are
2425    // handled before either pass), A10's TurnsCleared block still runs last,
2426    // and the P7 runaway-output safety net is preserved by an explicit
2427    // defer-to-A7 guard below: a changed re-read whose stub+diff view would
2428    // ITSELF still exceed `tool_output_trigger_bytes` is declined here (no
2429    // reduction minted) so A7's later scan bounds it exactly as before.
2430    // Every detected
2431    // read appends a `ReadLogEntry` to `log.read_log` regardless of whether it
2432    // ends up reduced (deduped by address so re-projection never duplicates
2433    // it) — this now runs whenever EITHER `elide_stale_reads` or
2434    // `diff_rereads` is set, since TR-3's read-log lookups must see every
2435    // read even when A8's own elision is disabled (and vice versa).
2436    //
2437    // **A8-vs-TR-3 precedence (SPEC.md TR-3 dev/05).** For a read of a path
2438    // already seen earlier this session (a re-read, per `log.read_log`):
2439    // - content hash UNCHANGED from that prior read -> TR-3 has nothing to
2440    //   show (a zero-hunk diff is never useful) and does not touch this
2441    //   index at all; A8's ordinary disk-freshness elision runs exactly as
2442    //   before, unaffected by TR-3 being enabled.
2443    // - content hash CHANGED from that prior read -> TR-3 takes EXCLUSIVE
2444    //   claim of this index (A8 never runs on it this call), because A8's
2445    //   elision message ("unchanged on disk, re-read on demand") would throw
2446    //   away the very fact that changed — either TR-3 diffs it (below the
2447    //   size guard) or, if the change is too large to compress usefully, the
2448    //   full re-read is left untouched in the view (dev/03's guard: showing
2449    //   the genuine rewrite beats hiding it behind an elision stub the model
2450    //   would have to spend a turn expanding).
2451    // A first-ever read of a path (no prior `log.read_log` entry) is never a
2452    // TR-3 candidate — there is nothing to diff against — and falls straight
2453    // through to A8, unaffected.
2454    if policy.elide_stale_reads || policy.diff_rereads {
2455        for d in detect_reads(&view) {
2456            let idx = d.index;
2457            if already_reduced.contains(&idx)
2458                || reduced_this_run.contains(&idx)
2459                || protected.contains(&idx)
2460                || in_existing_clear(idx)
2461            {
2462                continue;
2463            }
2464
2465            // Always resolved from `msgs` (never `view`): the pristine
2466            // canonical content at this index, regardless of processing
2467            // order within this call or any reduction already reapplied
2468            // onto `view` elsewhere.
2469            let original = msgs[idx].content.clone().unwrap_or_default();
2470            let hash = content_hash(original.as_bytes());
2471            let mtime = policy
2472                .read_freshness
2473                .entries
2474                .get(&idx)
2475                .and_then(|e| e.mtime);
2476            let addr = MessageAddr {
2477                index: idx,
2478                role: view[idx].role,
2479            };
2480
2481            // Most recent prior read of the SAME path, if any — always
2482            // resolved from `log.read_log`, whose `content_hash`/`addr` were
2483            // themselves minted from `msgs` (never from a reduced/diffed
2484            // view), so a diff's base is always a genuine full read, never
2485            // another diff (no diff-of-diff compounding, SPEC.md TR-3
2486            // dev/04).
2487            let prior_read: Option<ReadLogEntry> = log
2488                .read_log
2489                .iter()
2490                .filter(|e| e.path == d.path && e.addr.index < idx)
2491                .max_by_key(|e| e.addr.index)
2492                .cloned();
2493
2494            if !log.read_log.iter().any(|e| e.addr.index == idx) {
2495                log.read_log.push(ReadLogEntry {
2496                    path: d.path.clone(),
2497                    addr,
2498                    content_hash: hash.clone(),
2499                    mtime,
2500                });
2501            }
2502
2503            // ---- TR-3: diff-only re-read representation --------------
2504            //
2505            // `!d.windowed` guards TR-3.md's frozen v1 scope: "Partial-window
2506            // reads (offset/limit) are out of scope for v1 — full-file reads
2507            // only." A partial-window re-read never becomes a diff
2508            // candidate — its content is a slice, not a whole file, so a
2509            // unified diff against a prior read (whole or another slice)
2510            // would present a diff between two arbitrary windows as if it
2511            // were a genuine file change. Falls through to A8 exactly as a
2512            // non-windowed read would (A8 itself already fails closed on a
2513            // windowed read via `probe_read_freshness`'s hash-mismatch
2514            // fallback), and TR-2 is untouched by this check entirely (it
2515            // only ever consults same-path-re-read status, not windowing).
2516            let mut claimed_by_diff = false;
2517            if policy.diff_rereads && !d.windowed {
2518                if let Some(prior) = &prior_read {
2519                    if prior.content_hash != hash {
2520                        // Changed since the prior read of this path: from
2521                        // here, A8 must never touch this index (see the
2522                        // precedence note above) — whether or not the diff
2523                        // itself ends up below the size guard.
2524                        claimed_by_diff = true;
2525                        if let Some(base_text) = msgs
2526                            .get(prior.addr.index)
2527                            .and_then(|m| m.content.as_deref())
2528                        {
2529                            let diff_text = diffy::create_patch(base_text, &original).to_string();
2530                            let diff_bytes = diff_text.len();
2531                            let original_bytes = original.len();
2532                            let within_guard = (diff_bytes as u128).saturating_mul(100)
2533                                <= (original_bytes as u128) * policy.diff_max_percent as u128;
2534                            if within_guard {
2535                                // `ordinal` is only consumed on an actual
2536                                // mint (TR-2's guard rule), so computing the
2537                                // id/stub before the defer-to-A7 decision
2538                                // below stays deterministic either way.
2539                                let id = make_id(ordinal, &hash);
2540                                // explicit is-zero guard is clearer than checked_div here
2541                                #[allow(clippy::manual_checked_ops)]
2542                                let percent = if original_bytes == 0 {
2543                                    0
2544                                } else {
2545                                    diff_bytes * 100 / original_bytes
2546                                };
2547                                let summary = format!(
2548                                    "read {} diffed vs prior read at msg #{} — {}B diff, {}B full ({percent}%)",
2549                                    d.path.display(),
2550                                    prior.addr.index,
2551                                    format_commas(diff_bytes),
2552                                    format_commas(original_bytes),
2553                                );
2554                                let placeholder =
2555                                    stub::format(stub::Kind::FileReadDiffed, &id, &summary);
2556                                // Defer-to-A7 (TR-14): this pass now runs
2557                                // before A7, so a stub+diff view that would
2558                                // ITSELF still exceed A7's trigger must not
2559                                // claim the index — declining here leaves the
2560                                // full re-read for A7's own scan to bound
2561                                // (the P7 safety net), mirroring TR-4's
2562                                // defer-to-A7 rule.
2563                                if placeholder
2564                                    .len()
2565                                    .saturating_add(1)
2566                                    .saturating_add(diff_bytes)
2567                                    > policy.tool_output_trigger_bytes
2568                                {
2569                                    continue;
2570                                }
2571                                ordinal += 1;
2572                                let reduction = Reduction {
2573                                    id: id.clone(),
2574                                    kind: ReductionKind::FileReadDiffed {
2575                                        path: d.path.clone(),
2576                                        base: prior.addr,
2577                                        base_hash: prior.content_hash.clone(),
2578                                        new_hash: hash.clone(),
2579                                        original_bytes,
2580                                        diff_bytes,
2581                                    },
2582                                    ptr: SidecarPtr {
2583                                        addr,
2584                                        span: None,
2585                                        content_hash: hash.clone(),
2586                                    },
2587                                    placeholder,
2588                                };
2589
2590                                let mut new_content = reduction.placeholder.clone();
2591                                new_content.push('\n');
2592                                new_content.push_str(&diff_text);
2593                                view[idx].content = Some(new_content);
2594                                set_reduction_id(&mut view[idx], &reduction.id);
2595
2596                                reduced_this_run.insert(idx);
2597                                log.reductions.push(reduction);
2598                            }
2599                            // else: large-change guard tripped (dev/03) — no
2600                            // reduction of any kind; the full re-read stays.
2601                        }
2602                        // else: base unresolvable (should not happen against
2603                        // a stable `msgs`) — fail safe, leave the full
2604                        // re-read untouched rather than guess.
2605                    }
2606                }
2607            }
2608            if claimed_by_diff {
2609                continue; // Never let A8 elide a read TR-3 has claimed.
2610            }
2611
2612            // ---- A8: stale-file-read elision --------------------------
2613            if !policy.elide_stale_reads {
2614                continue;
2615            }
2616            let fresh = policy
2617                .read_freshness
2618                .entries
2619                .get(&idx)
2620                .is_some_and(|e| e.fresh);
2621            if !fresh {
2622                continue; // Changed or unreadable: the transcript copy stays the record.
2623            }
2624
2625            let id = make_id(ordinal, &hash);
2626            ordinal += 1;
2627            let summary = format!(
2628                "read {} elided — file unchanged on disk, re-read on demand",
2629                d.path.display()
2630            );
2631            let placeholder = stub::format(stub::Kind::FileRead, &id, &summary);
2632            let reduction = Reduction {
2633                id: id.clone(),
2634                kind: ReductionKind::FileReadElided {
2635                    path: d.path.clone(),
2636                    read_log: ReadLogEntry {
2637                        path: d.path.clone(),
2638                        addr,
2639                        content_hash: hash.clone(),
2640                        mtime,
2641                    },
2642                },
2643                ptr: SidecarPtr {
2644                    addr,
2645                    span: None,
2646                    content_hash: hash,
2647                },
2648                placeholder,
2649            };
2650
2651            view[idx].content = Some(reduction.placeholder.clone());
2652            set_reduction_id(&mut view[idx], &reduction.id);
2653
2654            reduced_this_run.insert(idx);
2655            log.reductions.push(reduction);
2656        }
2657    }
2658
2659    // Candidates: oversized tool results (never the system prompt, which is
2660    // role `System` and so never matches `role == Role::Tool` anyway), not
2661    // already reduced, not in the protected tail, not already inside an
2662    // established `TurnsCleared` range (about to vanish into its one
2663    // placeholder regardless), and not just claimed by TR-2's dedup pass,
2664    // T30/TR-4's normalize pass, or the A8/TR-3 read-family pass above
2665    // (TR-14: a re-read the read family diffed or elided is its claim; one it
2666    // DECLINED — changed too much, or diff still oversized — falls through to
2667    // this scan and is bounded here exactly as any oversized output).
2668    let mut candidates: Vec<usize> = view
2669        .iter()
2670        .enumerate()
2671        .filter(|(i, m)| {
2672            m.role == Role::Tool
2673                && !already_reduced.contains(i)
2674                && !reduced_this_run.contains(i)
2675                && !protected.contains(i)
2676                && !in_existing_clear(*i)
2677                && m.content.as_ref().map(|c| c.len()).unwrap_or(0)
2678                    > policy.tool_output_trigger_bytes
2679        })
2680        .map(|(i, _)| i)
2681        .collect();
2682
2683    // Largest-first (#6 "largest wins"); ties broken by ascending index for
2684    // determinism.
2685    let byte_len = |i: usize| view[i].content.as_ref().map(|c| c.len()).unwrap_or(0);
2686    candidates.sort_by(|&a, &b| byte_len(b).cmp(&byte_len(a)).then(a.cmp(&b)));
2687
2688    for idx in candidates {
2689        let original = view[idx].content.clone().unwrap_or_default();
2690        let original_bytes = original.len();
2691        let hash = content_hash(original.as_bytes());
2692        let id = make_id(ordinal, &hash);
2693        ordinal += 1;
2694
2695        let kept_bytes = char_boundary_floor(&original, policy.tool_output_keep_bytes);
2696        let tool_name = sanitize_summary_fragment(view[idx].name.as_deref().unwrap_or("tool"));
2697        let summary = format!(
2698            "{tool_name} output truncated {}B, kept {}B — full output in session sidecar",
2699            format_commas(original_bytes),
2700            format_commas(kept_bytes),
2701        );
2702        let placeholder = stub::format(stub::Kind::ToolOutput, &id, &summary);
2703
2704        let reduction = Reduction {
2705            id: id.clone(),
2706            kind: ReductionKind::ToolOutputTruncated {
2707                original_bytes,
2708                kept_bytes,
2709            },
2710            ptr: SidecarPtr {
2711                addr: MessageAddr {
2712                    index: idx,
2713                    role: view[idx].role,
2714                },
2715                span: Some((kept_bytes, original_bytes)),
2716                content_hash: hash,
2717            },
2718            placeholder,
2719        };
2720
2721        let mut new_content = original[..kept_bytes].to_string();
2722        new_content.push_str("\n\n");
2723        new_content.push_str(&reduction.placeholder);
2724        view[idx].content = Some(new_content);
2725        set_reduction_id(&mut view[idx], &reduction.id);
2726
2727        reduced_this_run.insert(idx);
2728        log.reductions.push(reduction);
2729    }
2730
2731    // ---- A9: ImageRedacted — data: URL image stripped to a stub + pointer ----
2732    //
2733    // Runs after A7/A8 (both cardinality-preserving, like this) and before
2734    // A10's TurnsCleared block (which must stay last — see above). Operates
2735    // on `content_parts`, an axis A7/A8 never touch, so there is no
2736    // cross-kind conflict to guard against the way A7 guards A8.
2737    if policy.redact_images {
2738        for img in detect_images(&view) {
2739            if img.url_len < policy.image_redact_min_bytes || in_existing_clear(img.msg_index) {
2740                continue;
2741            }
2742            let idx = img.msg_index;
2743            let part_index = img.part_index;
2744
2745            let original_part = view[idx].content_parts.as_ref().unwrap()[part_index].clone();
2746            let serialized = serde_json::to_vec(&original_part)
2747                .expect("a content part is always representable as JSON");
2748            let hash = content_hash(&serialized);
2749            let id = make_id(ordinal, &hash);
2750            ordinal += 1;
2751
2752            let size_kb = (img.url_len + 512) / 1024;
2753            let summary = format!("image redacted ({}, {size_kb}KB)", img.mime);
2754            let placeholder = stub::format(stub::Kind::Image, &id, &summary);
2755
2756            let reduction = Reduction {
2757                id: id.clone(),
2758                kind: ReductionKind::ImageRedacted { part_index },
2759                ptr: SidecarPtr {
2760                    addr: MessageAddr {
2761                        index: idx,
2762                        role: view[idx].role,
2763                    },
2764                    span: None,
2765                    content_hash: hash,
2766                },
2767                placeholder,
2768            };
2769
2770            let parts = view[idx].content_parts.as_mut().unwrap();
2771            parts[part_index] = serde_json::json!({"type": "text", "text": reduction.placeholder});
2772            set_reduction_id(&mut view[idx], &reduction.id);
2773
2774            reduced_this_run.insert(idx);
2775            log.reductions.push(reduction);
2776        }
2777    }
2778
2779    // ---- TR-10: ToolInputElided — the assistant-side twin of A7/A8 ----
2780    //
2781    // Runs after A7/A8/A9 (an independent axis: assistant `tool_calls`
2782    // arguments, never a `Role::Tool` result or a `content_parts` image, so
2783    // there is no cross-kind conflict to guard the way A7 guards A8) and
2784    // before A10's TurnsCleared block (which must stay last — see above).
2785    if policy.elide_tool_inputs {
2786        // Tracked per CALL id, not per message index (unlike `already_reduced`
2787        // above): a single assistant message can carry more than one
2788        // tool_calls entry, each independently elidable.
2789        let already_call_ids: HashSet<&str> = prior
2790            .reductions
2791            .iter()
2792            .filter_map(|r| match &r.kind {
2793                ReductionKind::ToolInputElided { call_id, .. } => Some(call_id.as_str()),
2794                _ => None,
2795            })
2796            .collect();
2797
2798        let mut candidates: Vec<DetectedToolInput> =
2799            detect_tool_inputs(&view, &policy.tool_input_elidable_fields)
2800                .into_iter()
2801                .filter(|d| {
2802                    !already_call_ids.contains(d.call_id.as_str())
2803                        && !in_existing_clear(d.msg_index)
2804                        && d.value.len() > policy.tool_input_trigger_bytes
2805                })
2806                .collect();
2807
2808        // Largest-first (#6 "largest wins"), ties broken by call_id for
2809        // determinism.
2810        candidates.sort_by(|a, b| {
2811            b.value
2812                .len()
2813                .cmp(&a.value.len())
2814                .then(a.call_id.cmp(&b.call_id))
2815        });
2816
2817        for d in candidates {
2818            let hash = content_hash(d.value.as_bytes());
2819            let id = make_id(ordinal, &hash);
2820            ordinal += 1;
2821            let original_bytes = d.value.len();
2822            let path_clause = d
2823                .path
2824                .as_ref()
2825                .map(|p| format!(", on disk at {}", p.display()))
2826                .unwrap_or_default();
2827            let hash_prefix: String = hash.chars().take(8).collect();
2828            let summary = format!(
2829                "{} input elided: `{}` field, {}B{path_clause}, blake3={hash_prefix}... — full \
2830                 args in session sidecar",
2831                d.tool_name,
2832                d.field,
2833                format_commas(original_bytes),
2834            );
2835            let placeholder = stub::format(stub::Kind::ToolInput, &id, &summary);
2836
2837            let Some(original_args) = view
2838                .get(d.msg_index)
2839                .and_then(|m| m.tool_calls.as_ref())
2840                .and_then(|calls| calls.iter().find(|c| c.id == d.call_id))
2841                .map(|call| call.function.arguments.clone())
2842            else {
2843                continue; // Addressed call no longer present; skip.
2844            };
2845            let Some(spliced) =
2846                replace_top_level_string_field(&original_args, &d.field, &placeholder)
2847            else {
2848                continue; // Field vanished/reshaped since detection; skip rather than corrupt.
2849            };
2850
2851            let role = view[d.msg_index].role;
2852            let calls = view[d.msg_index]
2853                .tool_calls
2854                .as_mut()
2855                .expect("checked above: this message has tool_calls");
2856            let call = calls
2857                .iter_mut()
2858                .find(|c| c.id == d.call_id)
2859                .expect("checked above: this call_id is present");
2860            call.function.arguments = spliced;
2861            set_reduction_id(&mut view[d.msg_index], &id);
2862
2863            let reduction = Reduction {
2864                id: id.clone(),
2865                kind: ReductionKind::ToolInputElided {
2866                    original_bytes,
2867                    path: d.path.clone(),
2868                    content_hash: hash.clone(),
2869                    call_id: d.call_id.clone(),
2870                    field: d.field.clone(),
2871                },
2872                ptr: SidecarPtr {
2873                    addr: MessageAddr {
2874                        index: d.msg_index,
2875                        role,
2876                    },
2877                    span: None,
2878                    content_hash: hash,
2879                },
2880                placeholder,
2881            };
2882            log.reductions.push(reduction);
2883        }
2884    }
2885
2886    // ---- TR-6: errored-input pruning — the FAILURE-side complement of TR-10 ----
2887    //
2888    // Same address space as TR-10 above (assistant `tool_calls` arguments,
2889    // keyed by `call_id`), but structurally disjoint from it: `detect_tool_inputs`
2890    // only ever matches a call whose paired result is `KnownSuccess`
2891    // (TR-10); `detect_errored_tool_inputs` only ever matches `KnownError`
2892    // (this pass) — an `Unknown` result matches neither, and a call id can never
2893    // satisfy both, so `already_call_ids` (recomputed here rather than shared
2894    // with TR-10's own local binding above, since either gate may be off
2895    // independently of the other) is sufficient with no extra bookkeeping to
2896    // keep the two disjoint. Also gated by an aging clock TR-10 has no
2897    // equivalent of ([`assistant_turns_since`] vs. `policy.errored_input_prune_after_turns`)
2898    // — a freshly-failed call's input stays visible for a while (in case the
2899    // model wants to see exactly what it just tried) and only becomes a
2900    // candidate once it's aged past that. The paired error-result message
2901    // itself (the actual error text) is never touched here — only the
2902    // assistant-side input argument — so the error stays visible exactly as
2903    // TR-6.md requires.
2904    if policy.prune_errored_inputs {
2905        let already_call_ids: HashSet<&str> = prior
2906            .reductions
2907            .iter()
2908            .filter_map(|r| match &r.kind {
2909                ReductionKind::ToolInputElided { call_id, .. } => Some(call_id.as_str()),
2910                _ => None,
2911            })
2912            .collect();
2913
2914        let mut candidates: Vec<DetectedToolInput> =
2915            detect_errored_tool_inputs(&view, &policy.tool_input_elidable_fields)
2916                .into_iter()
2917                .filter(|d| {
2918                    !already_call_ids.contains(d.call_id.as_str())
2919                        && !in_existing_clear(d.msg_index)
2920                        && d.value.len() > policy.tool_input_trigger_bytes
2921                        && assistant_turns_since(&view, d.msg_index)
2922                            >= policy.errored_input_prune_after_turns
2923                })
2924                .collect();
2925
2926        // Largest-first (#6 "largest wins"), ties broken by call_id for
2927        // determinism — identical convention to TR-10's own candidate sort.
2928        candidates.sort_by(|a, b| {
2929            b.value
2930                .len()
2931                .cmp(&a.value.len())
2932                .then(a.call_id.cmp(&b.call_id))
2933        });
2934
2935        for d in candidates {
2936            let hash = content_hash(d.value.as_bytes());
2937            let id = make_id(ordinal, &hash);
2938            ordinal += 1;
2939            let original_bytes = d.value.len();
2940            let turns = assistant_turns_since(&view, d.msg_index);
2941            let path_clause = d
2942                .path
2943                .as_ref()
2944                .map(|p| format!(", on disk at {}", p.display()))
2945                .unwrap_or_default();
2946            let hash_prefix: String = hash.chars().take(8).collect();
2947            let summary = format!(
2948                "{} input elided (errored call, {turns} turns old): `{}` field, {}B{path_clause}, \
2949                 blake3={hash_prefix}... — full args in session sidecar",
2950                d.tool_name,
2951                d.field,
2952                format_commas(original_bytes),
2953            );
2954            let placeholder = stub::format(stub::Kind::ToolInput, &id, &summary);
2955
2956            let Some(original_args) = view
2957                .get(d.msg_index)
2958                .and_then(|m| m.tool_calls.as_ref())
2959                .and_then(|calls| calls.iter().find(|c| c.id == d.call_id))
2960                .map(|call| call.function.arguments.clone())
2961            else {
2962                continue; // Addressed call no longer present; skip.
2963            };
2964            let Some(spliced) =
2965                replace_top_level_string_field(&original_args, &d.field, &placeholder)
2966            else {
2967                continue; // Field vanished/reshaped since detection; skip rather than corrupt.
2968            };
2969
2970            let role = view[d.msg_index].role;
2971            let calls = view[d.msg_index]
2972                .tool_calls
2973                .as_mut()
2974                .expect("checked above: this message has tool_calls");
2975            let call = calls
2976                .iter_mut()
2977                .find(|c| c.id == d.call_id)
2978                .expect("checked above: this call_id is present");
2979            call.function.arguments = spliced;
2980            set_reduction_id(&mut view[d.msg_index], &id);
2981
2982            let reduction = Reduction {
2983                id: id.clone(),
2984                kind: ReductionKind::ToolInputElided {
2985                    original_bytes,
2986                    path: d.path.clone(),
2987                    content_hash: hash.clone(),
2988                    call_id: d.call_id.clone(),
2989                    field: d.field.clone(),
2990                },
2991                ptr: SidecarPtr {
2992                    addr: MessageAddr {
2993                        index: d.msg_index,
2994                        role,
2995                    },
2996                    span: None,
2997                    content_hash: hash,
2998                },
2999                placeholder,
3000            };
3001            log.reductions.push(reduction);
3002        }
3003    }
3004
3005    // ---- A10: TurnsCleared — old-turn clearing, re-founding `maybe_compact` ----
3006    //
3007    // `view` is still fully index-parallel to `msgs` at this point (every
3008    // step above only mutated content in place); this is the one step that
3009    // changes cardinality, so it must run last.
3010    if !existing_clears.is_empty() {
3011        // Already established in a prior projection: reapply EVERY existing
3012        // record verbatim, never recompute or widen any range (prefix
3013        // stability, A5) — descending by `first` so an earlier splice's
3014        // cardinality shrink never invalidates a later, still-`msgs`-indexed
3015        // splice (mirrors TR-9 `handoff`'s own last-gap-first splice order).
3016        let mut to_reapply: Vec<Reduction> = log
3017            .reductions
3018            .iter()
3019            .filter(|r| matches!(r.kind, ReductionKind::TurnsCleared { .. }))
3020            .cloned()
3021            .collect();
3022        to_reapply.sort_by_key(|r| match r.kind {
3023            ReductionKind::TurnsCleared { first, .. } => std::cmp::Reverse(first),
3024            _ => unreachable!("filtered to TurnsCleared above"),
3025        });
3026        for r in &to_reapply {
3027            reapply_reduction(&mut view, r, msgs);
3028        }
3029    } else if let Some((first, last)) = compute_clear_range(&view, policy) {
3030        // `view`'s roles are identical to `msgs`'s at every index up to this
3031        // point (every pass above only mutates content/tool_calls in place),
3032        // so `compute_clear_range` — a pure function of role/length alone —
3033        // derives the identical range whether called against `view` (here)
3034        // or `msgs` directly ([`prepare_cleared_turns_summary`], called
3035        // BEFORE this projection even runs). This is what lets the two
3036        // agree by construction; see `compute_clear_range`'s own doc.
3037        //
3038        // `msgs` is `history[1..]` (via `project_messages`, called from
3039        // `Agent::build_request_messages`). The hash below covers the true
3040        // sidecar bytes — never an already-truncated copy — SOLELY because
3041        // `Agent::run_loop`'s D6/A7 supersession gate (TR-12) keeps
3042        // `cap_tool_output` off whenever a recorder + this policy are both
3043        // active (the only combination `project_messages`/mint ever runs
3044        // under with a durable sidecar behind it): `history` then holds full
3045        // bytes by construction, so this slice already equals
3046        // `sidecar.messages`. Without that gate a legacy `cap_tool_output`
3047        // could shrink `msgs` first, and this comment's claim would be false
3048        // — exactly the land-blocker TR-12 fixed (a hash minted from capped
3049        // bytes can never recompute the same way from the reloaded,
3050        // full-bytes sidecar).
3051        let range = &msgs[first..=last];
3052        let (hash, range_bytes) =
3053            hash_turns_range(range).expect("ChatMessage always serializes to JSON");
3054        let (user, assistant, tool) = count_roles(range);
3055        let id = make_id(ordinal, &hash);
3056        let deterministic_summary = format!(
3057            "turns {first}..{} cleared ({} messages: {user} user, {assistant} \
3058             assistant, {tool} tool; {}B) — full turns in session sidecar",
3059            last + 1,
3060            format_commas(range.len()),
3061            format_commas(range_bytes),
3062        );
3063
3064        // TR-7 (T20): off by default (`summarize_cleared_turns: false`,
3065        // SPEC.md dev/01) — the placeholder below is then byte-identical to
3066        // pre-TR-7 behavior, full stop. When on AND a prepared summary
3067        // exists for EXACTLY this `(first, last)` range (computed by
3068        // `prepare_cleared_turns_summary`, called by the driving caller
3069        // BEFORE this projection — the one place TR-7's side-call happens,
3070        // mirroring A8's `probe_read_freshness` split so this function
3071        // itself stays pure/I-O-free), the placeholder instead carries the
3072        // LLM summary text plus an honesty banner naming this span's
3073        // reduction id and turn count, with `expand_reduction("<id>")`
3074        // spelled out as the verbatim escape hatch. Any mismatch (off,
3075        // absent, or a stale/wrong-range entry) falls back to the
3076        // deterministic stub — dev/03's failure-fallback guarantee applies
3077        // equally to "never prepared" and "errored while preparing".
3078        let prepared = policy
3079            .summarize_cleared_turns
3080            .then_some(policy.cleared_turns_summary.as_ref())
3081            .flatten()
3082            .filter(|p| p.first == first && p.last == last);
3083
3084        let (summary_text, summary_audit) = match prepared {
3085            Some(p) => {
3086                let banner = format!(
3087                    "sc-summary of {id}, original {} messages in sidecar — \
3088                     expand_reduction(\"{id}\") for verbatim",
3089                    format_commas(range.len()),
3090                );
3091                let text = format!("{} ({banner})", p.text);
3092                let audit = SpanSummary {
3093                    model_id: p.model_id.clone(),
3094                    prompt_version: summarize::PROMPT_VERSION.to_string(),
3095                    summary_hash: content_hash(p.text.as_bytes()),
3096                };
3097                (text, Some(audit))
3098            }
3099            None => (deterministic_summary, None),
3100        };
3101        let placeholder = stub::format(stub::Kind::TurnsCleared, &id, &summary_text);
3102
3103        // Any reduction (of any kind) whose address falls inside the
3104        // range about to be collapsed is now subsumed by this single
3105        // placeholder — drop it from the log; `invert` restores the
3106        // WHOLE range straight from the sidecar (`resolve_turns_range`),
3107        // so nothing recorded there is lost.
3108        log.reductions
3109            .retain(|r| !(r.ptr.addr.index >= first && r.ptr.addr.index <= last));
3110
3111        let reduction = Reduction {
3112            id: id.clone(),
3113            kind: ReductionKind::TurnsCleared {
3114                first,
3115                last,
3116                summary: summary_audit,
3117            },
3118            ptr: SidecarPtr {
3119                addr: MessageAddr {
3120                    index: first,
3121                    role: range[0].role,
3122                },
3123                span: None,
3124                content_hash: hash,
3125            },
3126            placeholder,
3127        };
3128
3129        let mut stub_msg = ChatMessage::system(reduction.placeholder.clone());
3130        set_reduction_id(&mut stub_msg, &reduction.id);
3131        view.splice(first..=last, std::iter::once(stub_msg));
3132
3133        log.reductions.push(reduction);
3134    }
3135
3136    (view, log)
3137}
3138
3139fn next_reduction_ordinal<'a>(ids: impl Iterator<Item = &'a str>) -> usize {
3140    ids.filter_map(|id| {
3141        let digits = id.strip_prefix('r')?.get(..4)?;
3142        digits.parse::<usize>().ok()
3143    })
3144    .max()
3145    .map_or(0, |max| max.saturating_add(1))
3146}
3147
3148#[cfg(test)]
3149mod ordinal_tests {
3150    use super::next_reduction_ordinal;
3151
3152    #[test]
3153    fn sparse_legacy_ids_advance_past_the_greatest_live_ordinal() {
3154        let ids = ["r0001-dead", "r0007-beef"];
3155        assert_eq!(next_reduction_ordinal(ids.into_iter()), 8);
3156    }
3157
3158    #[test]
3159    fn malformed_ids_cannot_force_reuse_of_a_valid_live_ordinal() {
3160        let ids = ["legacy", "r0003-cafe", "rxxxx-nope"];
3161        assert_eq!(next_reduction_ordinal(ids.into_iter()), 4);
3162    }
3163}
3164
3165/// PARITY-18 — how many times [`reduce_to_fit`] will re-project with a
3166/// tighter [`ReductionPolicy`] before giving up and returning its best
3167/// (tightest-attempted) effort. Each level roughly halves the byte-based
3168/// knobs (see [`tighten`]), so 5 levels covers a ~32x tightening range —
3169/// past that, more aggression stops buying meaningfully more headroom and
3170/// the caller's preflight guard (`crates/cli`'s `resume_cmd`) should fail
3171/// fast instead of looping forever.
3172const MAX_AGGRESSIVE_LEVELS: u32 = 5;
3173
3174/// PARITY-18 — a strictly tighter variant of `base` for [`reduce_to_fit`]'s
3175/// escalation ladder. Only scales knobs that are pure functions of in-view
3176/// content (byte thresholds, protected-recency windows) — exactly the set
3177/// [`ReductionPolicy::default`] already turns on unconditionally (D14) —
3178/// never touches `elide_stale_reads`/`read_freshness` or
3179/// `summarize_cleared_turns`/`cleared_turns_summary`, both of which need
3180/// data only a live disk probe or an LLM side-call can produce and so stay
3181/// exactly as the caller configured them at every level. `level` is
3182/// 1-indexed (`level=0` would be `base` itself, never called that way here).
3183fn tighten(base: &ReductionPolicy, level: u32) -> ReductionPolicy {
3184    let shift = level.min(5);
3185    let shrink = |n: usize, floor: usize| -> usize { (n >> shift).max(floor) };
3186    ReductionPolicy {
3187        tool_output_keep_bytes: shrink(base.tool_output_keep_bytes, 128),
3188        tool_output_trigger_bytes: shrink(base.tool_output_trigger_bytes, 256),
3189        protect_last_n_tool_results: base
3190            .protect_last_n_tool_results
3191            .saturating_sub(level as usize),
3192        image_redact_min_bytes: shrink(base.image_redact_min_bytes, 256),
3193        tool_input_trigger_bytes: shrink(base.tool_input_trigger_bytes, 256),
3194        duplicate_output_min_bytes: shrink(base.duplicate_output_min_bytes, 16),
3195        supersede_min_bytes: shrink(base.supersede_min_bytes, 16),
3196        supersede_protect_last_n: base.supersede_protect_last_n.saturating_sub(level as usize),
3197        errored_input_prune_after_turns: base
3198            .errored_input_prune_after_turns
3199            .saturating_sub(level as usize),
3200        ..base.clone()
3201    }
3202}
3203
3204/// PARITY-18 v3 — the "aggressive reduction" half of the fix (SPEC.md
3205/// scaling-context-guard): apply [`project_messages`] with `base_policy`
3206/// first, then, if `fits` says the projected view still doesn't pass,
3207/// retry with progressively tighter policies (see `tighten`) up to
3208/// `MAX_AGGRESSIVE_LEVELS` times, then escalate to A10 turn-clearing,
3209/// keeping whichever attempt is smallest. Never fails and never loops
3210/// unboundedly — it always returns SOME projection (the caller's own
3211/// preflight guard is responsible for deciding whether even the tightest
3212/// attempt still exceeds the target model's context limit and refusing to
3213/// send in that case, PARITY-18 dev/01).
3214///
3215/// `fits` is a closure over the REDUCED VIEW ALONE (this function has no
3216/// knowledge of the system prompt, tool schemas, or a trailing user
3217/// prompt — those live with the caller). It exists to close a v2 defect a
3218/// skeptical review caught (the flagship rescue-path regression): v2 had
3219/// two INDEPENDENTLY-derived boundaries — this function stopped escalating
3220/// once `estimate_view_tokens(view) <= target_tokens` where
3221/// `target_tokens = context_limit - CONTEXT_RESPONSE_RESERVE_TOKENS`, while
3222/// `tokens::context_guard` only ACCEPTS a request when
3223/// `with_guard_margin(view + tools) + CONTEXT_RESPONSE_RESERVE_TOKENS <=
3224/// context_limit` — a materially tighter bound (no margin, no tools, no
3225/// system-prompt overhead counted by the reducer's stop condition at all).
3226/// Any session whose reduced view landed in the band between those two
3227/// boundaries was declared "fits" here and then refused by the guard, with
3228/// D6 turn-clearing never triggered because this function had already
3229/// stopped. Passing `tokens::context_guard` itself (wrapped with the
3230/// caller's real system prompt/tools/prompt) as `fits` makes that
3231/// impossible BY CONSTRUCTION: the reducer's stopping condition and the
3232/// guard's acceptance are now the same boundary, so a session this function
3233/// says it reduced-to-fit is a session the guard will actually send.
3234/// (`resume_cmd` is the sole real caller; see its `fits` closure there.)
3235///
3236/// Returns `(view, log, policy_actually_applied)` so the caller can seed
3237/// a runtime agent's reduction policy/log setters with the exact
3238/// policy that produced the returned view (subsequent turns keep re-applying
3239/// it, `resume_cmd`'s existing re-reduce path).
3240pub fn reduce_to_fit<F>(
3241    full_msgs: &[ChatMessage],
3242    base_policy: &ReductionPolicy,
3243    prior_log: &ReductionLog,
3244    fits: F,
3245) -> (Vec<ChatMessage>, ReductionLog, ReductionPolicy)
3246where
3247    F: Fn(&[ChatMessage]) -> bool,
3248{
3249    let (mut best_view, mut best_log) = project_messages(full_msgs, base_policy, prior_log);
3250    let mut best_policy = base_policy.clone();
3251    let mut best_tokens = estimate_view_tokens(&best_view);
3252
3253    let mut level = 1;
3254    while !fits(&best_view) && level <= MAX_AGGRESSIVE_LEVELS {
3255        let candidate_policy = tighten(base_policy, level);
3256        let (view, log) = project_messages(full_msgs, &candidate_policy, prior_log);
3257        let view_tokens = estimate_view_tokens(&view);
3258        if view_tokens < best_tokens {
3259            best_view = view;
3260            best_log = log;
3261            best_policy = candidate_policy;
3262            best_tokens = view_tokens;
3263        }
3264        level += 1;
3265    }
3266
3267    // PARITY-18 D6 — [`tighten`] only scales byte-based knobs (tool output,
3268    // images, tool input), so a TEXT-heavy session (mostly plain user/
3269    // assistant turns, little/no tool output to shrink) can exhaust every
3270    // level above and still not `fits`. Escalate to A10 turn-clearing
3271    // (`clear_turns_older_than`) as a last resort: stacked on top of
3272    // whatever byte-knob tightening already achieved (`best_policy`),
3273    // progressively HALVING the surviving message-count window until the
3274    // projection fits OR the window bottoms out at
3275    // [`MIN_CLEAR_TURNS_WINDOW`]. Reversible, not lossy —
3276    // `ReductionKind::TurnsCleared` stubs are hash-verified and rehydrate
3277    // byte-exact from the sidecar via `expand_reduction`/`invert`
3278    // (dev/03 fidelity is untouched: nothing here bypasses that path).
3279    // Gated on `base_policy.clear_turns_older_than.is_none()` so this never
3280    // overrides an EXPLICIT threshold a caller already set (e.g.
3281    // `Agent::maybe_compact`'s own live-session clearing, which never calls
3282    // `reduce_to_fit` at all, but the guard costs nothing to keep honest).
3283    //
3284    // PARITY-18 v3 — this loop previously ran at most `MAX_AGGRESSIVE_LEVELS`
3285    // (5) halvings starting from `full_msgs.len()`, so any session over
3286    // ~128 messages bottomed out well above the floor (a 3,000-message
3287    // session stopped at ~93, never reaching 4) — "maximal reduction"
3288    // wasn't actually maximal, which weakened the honesty of a genuine
3289    // refusal (it would refuse having never tried the smallest window).
3290    // The loop now keeps halving unconditionally until `threshold` reaches
3291    // [`MIN_CLEAR_TURNS_WINDOW`] regardless of the starting length — still
3292    // `O(log2(full_msgs.len()))` iterations, so it stays bounded — or until
3293    // `fits` succeeds, whichever comes first.
3294    if !fits(&best_view) && base_policy.clear_turns_older_than.is_none() {
3295        let mut threshold = full_msgs.len();
3296        loop {
3297            if threshold <= MIN_CLEAR_TURNS_WINDOW {
3298                break;
3299            }
3300            threshold = (threshold / 2).max(MIN_CLEAR_TURNS_WINDOW);
3301            let mut candidate_policy = best_policy.clone();
3302            candidate_policy.clear_turns_older_than = Some(threshold);
3303            let (view, log) = project_messages(full_msgs, &candidate_policy, prior_log);
3304            let view_tokens = estimate_view_tokens(&view);
3305            if view_tokens < best_tokens {
3306                best_view = view;
3307                best_log = log;
3308                best_policy = candidate_policy;
3309                best_tokens = view_tokens;
3310            }
3311            if fits(&best_view) {
3312                break;
3313            }
3314        }
3315    }
3316
3317    (best_view, best_log, best_policy)
3318}
3319
3320/// PARITY-18 D6 — the smallest surviving message-count window
3321/// [`reduce_to_fit`]'s A10 escalation will ever request via
3322/// `clear_turns_older_than`. `compute_clear_range` itself already floors
3323/// `keep_recent` at `2`; `4` here leaves a little more headroom (at least
3324/// one full user/assistant exchange) before giving up rather than shrinking
3325/// the window to the bare minimum every level.
3326const MIN_CLEAR_TURNS_WINDOW: usize = 4;
3327
3328// ---------------------------------------------------------------------------
3329// A6 — invert() / invert_one(): reduced view + log + sidecar -> full view
3330// ---------------------------------------------------------------------------
3331
3332/// Resolve and hash-verify the full original content standing behind `ptr`
3333/// (kind-agnostic: [`ReductionKind::ToolOutputTruncated`],
3334/// [`ReductionKind::FileReadElided`], [`ReductionKind::OutputNormalized`],
3335/// [`ReductionKind::FileReadDiffed`], and [`ReductionKind::DuplicateOutput`]
3336/// all restore by replacing the reduced message's whole content with this —
3337/// for `OutputNormalized` this is the RAW captured bytes, byte-exact, never
3338/// the normalized/rendered text: the sidecar only ever stores the raw
3339/// capture, so this same kind-agnostic resolve path already restores it
3340/// correctly with no `OutputNormalized`-specific branch needed here).
3341///
3342/// Takes the resolved-against message slice directly (rather than a
3343/// [`Session`]) so [`rehydrate`]'s model-invocable path can resolve against a
3344/// live `Agent`'s own `history[1..]` — which the sidecar invariant
3345/// (`Agent::resume_recorded`'s doc comment) guarantees is byte-identical to
3346/// `Session::from_native_str(sidecar).messages` at every instant **once a
3347/// recorder and a [`ReductionPolicy`] are both installed** (TR-12's D6/A7
3348/// supersession gate in `Agent::run_loop`, the only combination under which
3349/// any reduction here is ever minted with a durable sidecar behind it) —
3350/// without needing to round-trip through disk in that case.
3351/// `invert`/`invert_one`/`verify_log` (the offline, `Session`-backed callers)
3352/// simply pass `sidecar.messages`, so they need no such gate to already hold:
3353/// a hash minted under the gate recomputes identically from either slice by
3354/// construction; a hash minted from a pre-gate legacy sidecar instead fails
3355/// safe here (content_hash mismatch) rather than resolving to the wrong
3356/// bytes.
3357fn resolve_original_content(ptr: &SidecarPtr, messages: &[ChatMessage]) -> Result<String> {
3358    let msg = messages.get(ptr.addr.index).ok_or_else(|| {
3359        Error::new(format!(
3360            "invert: sidecar has no message at index {} (reduction pointer unresolvable)",
3361            ptr.addr.index
3362        ))
3363    })?;
3364    if msg.role != ptr.addr.role {
3365        return Err(Error::new(format!(
3366            "invert: role mismatch at sidecar index {}: pointer expects {:?}, sidecar has {:?}",
3367            ptr.addr.index, ptr.addr.role, msg.role
3368        )));
3369    }
3370    let content = msg.content.clone().unwrap_or_default();
3371    ptr.verify(content.as_bytes())?;
3372    Ok(content)
3373}
3374
3375/// Resolve and hash-verify the original image content part standing behind an
3376/// [`ReductionKind::ImageRedacted`] pointer. See [`resolve_original_content`]
3377/// for why this takes a message slice rather than a [`Session`].
3378fn resolve_image_part(
3379    ptr: &SidecarPtr,
3380    part_index: usize,
3381    messages: &[ChatMessage],
3382) -> Result<serde_json::Value> {
3383    let msg = messages.get(ptr.addr.index).ok_or_else(|| {
3384        Error::new(format!(
3385            "invert: sidecar has no message at index {} (reduction pointer unresolvable)",
3386            ptr.addr.index
3387        ))
3388    })?;
3389    if msg.role != ptr.addr.role {
3390        return Err(Error::new(format!(
3391            "invert: role mismatch at sidecar index {}: pointer expects {:?}, sidecar has {:?}",
3392            ptr.addr.index, ptr.addr.role, msg.role
3393        )));
3394    }
3395    let part = msg
3396        .content_parts
3397        .as_ref()
3398        .and_then(|parts| parts.get(part_index))
3399        .ok_or_else(|| {
3400            Error::new(format!(
3401                "invert: sidecar message at index {} has no content part {part_index}",
3402                ptr.addr.index
3403            ))
3404        })?;
3405    let serialized = serde_json::to_vec(part)
3406        .map_err(|e| Error::new(format!("invert: failed to serialize image part: {e}")))?;
3407    ptr.verify(&serialized)?;
3408    Ok(part.clone())
3409}
3410
3411/// Resolve and hash-verify the original message range `first..=last` standing
3412/// behind a [`ReductionKind::TurnsCleared`] pointer. The hash covers each
3413/// message's wire-serialized bytes (role/content/tool_calls/tool_call_id/name
3414/// — the same shape [`ChatMessage`]'s custom `Serialize` puts on the wire),
3415/// concatenated in order. See [`resolve_original_content`] for why this takes
3416/// a message slice rather than a [`Session`].
3417fn resolve_turns_range(
3418    ptr: &SidecarPtr,
3419    first: usize,
3420    last: usize,
3421    messages: &[ChatMessage],
3422) -> Result<Vec<ChatMessage>> {
3423    let mut msgs = Vec::with_capacity(last.saturating_sub(first) + 1);
3424    for i in first..=last {
3425        let msg = messages.get(i).ok_or_else(|| {
3426            Error::new(format!(
3427                "invert: sidecar has no message at index {i} (turns-cleared range unresolvable)"
3428            ))
3429        })?;
3430        msgs.push(msg.clone());
3431    }
3432    if let Some(first_msg) = msgs.first() {
3433        if first_msg.role != ptr.addr.role {
3434            return Err(Error::new(format!(
3435                "invert: role mismatch at sidecar index {first}: pointer expects {:?}, sidecar has {:?}",
3436                ptr.addr.role, first_msg.role
3437            )));
3438        }
3439    }
3440    // Shared with the creating side (`hash_turns_range`, A10) so the two
3441    // formulas can never drift apart: blake3 over each message's wire-
3442    // serialized bytes, concatenated in order. (The byte length only matters
3443    // to the creating side's stub summary — ignored here.)
3444    let (hash, _bytes) = hash_turns_range(&msgs)?;
3445    ptr.verify_hash(&hash)?;
3446    Ok(msgs)
3447}
3448
3449/// Rehydrate one reduction `r` in place within `out`: locate the placeholder
3450/// message by its `sc.reduction` id, then restore it from `sidecar`
3451/// (hash-verified). `TurnsCleared` splices the original message range back in
3452/// place of the single placeholder message.
3453fn invert_one_reduction(
3454    out: &mut Vec<ChatMessage>,
3455    r: &Reduction,
3456    sidecar_messages: &[ChatMessage],
3457) -> Result<()> {
3458    let pos = out
3459        .iter()
3460        .position(|m| reduction_id(m) == Some(r.id.as_str()))
3461        .ok_or_else(|| {
3462            Error::new(format!(
3463                "invert: no message in the reduced view carries reduction id {}",
3464                r.id
3465            ))
3466        })?;
3467
3468    match &r.kind {
3469        ReductionKind::ToolOutputTruncated { .. }
3470        | ReductionKind::FileReadElided { .. }
3471        | ReductionKind::OutputNormalized { .. }
3472        | ReductionKind::FileReadDiffed { .. }
3473        | ReductionKind::DuplicateOutput { .. }
3474        | ReductionKind::Superseded { .. } => {
3475            let original = resolve_original_content(&r.ptr, sidecar_messages)?;
3476            out[pos].content = Some(original);
3477            out[pos].metadata.remove(REDUCTION_METADATA_KEY);
3478        }
3479        ReductionKind::ImageRedacted { part_index } => {
3480            let part = resolve_image_part(&r.ptr, *part_index, sidecar_messages)?;
3481            let parts = out[pos].content_parts.get_or_insert_with(Vec::new);
3482            if *part_index < parts.len() {
3483                parts[*part_index] = part;
3484            } else {
3485                parts.push(part);
3486            }
3487            out[pos].metadata.remove(REDUCTION_METADATA_KEY);
3488        }
3489        ReductionKind::TurnsCleared { first, last, .. } => {
3490            let msgs = resolve_turns_range(&r.ptr, *first, *last, sidecar_messages)?;
3491            out.splice(pos..=pos, msgs);
3492        }
3493        ReductionKind::ToolInputElided { call_id, field, .. } => {
3494            let original_value =
3495                resolve_tool_input_value(&r.ptr, call_id, field, sidecar_messages)?;
3496            let current_args = out[pos]
3497                .tool_calls
3498                .as_ref()
3499                .and_then(|calls| calls.iter().find(|c| &c.id == call_id))
3500                .map(|call| call.function.arguments.clone())
3501                .ok_or_else(|| {
3502                    Error::new(format!(
3503                        "invert: reduced message at position {pos} has no tool_call with id \
3504                         {call_id}"
3505                    ))
3506                })?;
3507            // Splicing the recovered ORIGINAL value back into the CURRENT
3508            // (reduced) arguments string at the same field's span restores
3509            // the pristine bytes exactly: the reduced string differs from
3510            // the original ONLY in that one field's value (the forward
3511            // splice at project-time never touched anything else), so this
3512            // is byte-exact by construction — no separate "store the whole
3513            // original args" bookkeeping needed.
3514            let restored = replace_top_level_string_field(&current_args, field, &original_value)
3515                .ok_or_else(|| {
3516                    Error::new(format!(
3517                        "invert: reduced tool_call {call_id} arguments do not contain field \
3518                         `{field}` to restore"
3519                    ))
3520                })?;
3521            let calls = out[pos]
3522                .tool_calls
3523                .as_mut()
3524                .expect("checked above: tool_calls present");
3525            let call = calls
3526                .iter_mut()
3527                .find(|c| &c.id == call_id)
3528                .expect("checked above: call_id present");
3529            call.function.arguments = restored;
3530            out[pos].metadata.remove(REDUCTION_METADATA_KEY);
3531        }
3532    }
3533    Ok(())
3534}
3535
3536/// Reconstruct the full view: every placeholder in `reduced` replaced by the
3537/// original content resolved from `sidecar_session` (the ONE canonical
3538/// model — a canonical message slice reconstructed from its sidecar),
3539/// hash-verified against [`SidecarPtr::content_hash`] before ever
3540/// substituting it in.
3541///
3542/// Fails loudly (`Err`) rather than silently partial: on any reduced message
3543/// whose `sc.reduction` id has no matching entry in `log` (an unresolvable
3544/// pointer — e.g. the log entry was deleted), on any pointer that no longer
3545/// resolves in the sidecar, or on any hash mismatch (a stale/foreign/tampered
3546/// sidecar).
3547///
3548/// Strips the `sc.reduction` bookkeeping key from every message's metadata —
3549/// it never survives inversion.
3550pub fn invert_messages(
3551    reduced: &[ChatMessage],
3552    log: &ReductionLog,
3553    sidecar_messages: &[ChatMessage],
3554) -> Result<Vec<ChatMessage>> {
3555    let mut out: Vec<ChatMessage> = reduced.to_vec();
3556
3557    // Every reduced (stub-bearing) message must resolve to a log entry —
3558    // otherwise it is an unresolvable pointer by design (SPEC.md A6(b)).
3559    let by_id: HashMap<&str, &Reduction> =
3560        log.reductions.iter().map(|r| (r.id.as_str(), r)).collect();
3561    for msg in &out {
3562        if let Some(id) = reduction_id(msg) {
3563            if !by_id.contains_key(id) {
3564                return Err(Error::new(format!(
3565                    "invert: reduced message carries reduction id {id} with no matching entry \
3566                     in the reduction log — unresolvable pointer"
3567                )));
3568            }
3569        }
3570    }
3571
3572    for r in &log.reductions {
3573        invert_one_reduction(&mut out, r, sidecar_messages)?;
3574    }
3575
3576    for msg in out.iter_mut() {
3577        msg.metadata.remove(REDUCTION_METADATA_KEY);
3578    }
3579
3580    Ok(out)
3581}
3582
3583/// Verify every reduction in `log` resolves against `sidecar` — the same
3584/// hash-verify path `invert`/`invert_one` walk before ever substituting
3585/// content back in, without needing an actual reduced view to substitute
3586/// into. This is the user-facing detector for a broken transparency
3587/// invariant (C2 contract 3): `sessions show-reductions` (C4) and `convert`
3588/// (C7) both call this before doing anything else with a reduced session, so
3589/// a corrupt/tampered/stale sidecar is reported — naming the offending
3590/// record id — before any output is produced, rather than surfacing as a
3591/// confusing downstream failure (or, worse, silently substituting the wrong
3592/// content).
3593///
3594/// Returns the first offending record's error (which names its `id`); `Ok`
3595/// means every record in `log` resolves and hash-verifies cleanly.
3596pub fn verify_log_messages(log: &ReductionLog, sidecar_messages: &[ChatMessage]) -> Result<()> {
3597    for r in log.reductions.iter().chain(log.expanded.iter()) {
3598        let resolved = match &r.kind {
3599            ReductionKind::ToolOutputTruncated { .. }
3600            | ReductionKind::FileReadElided { .. }
3601            | ReductionKind::OutputNormalized { .. }
3602            | ReductionKind::FileReadDiffed { .. }
3603            | ReductionKind::DuplicateOutput { .. }
3604            | ReductionKind::Superseded { .. } => {
3605                resolve_original_content(&r.ptr, sidecar_messages).map(|_| ())
3606            }
3607            ReductionKind::ImageRedacted { part_index } => {
3608                resolve_image_part(&r.ptr, *part_index, sidecar_messages).map(|_| ())
3609            }
3610            ReductionKind::TurnsCleared { first, last, .. } => {
3611                resolve_turns_range(&r.ptr, *first, *last, sidecar_messages).map(|_| ())
3612            }
3613            ReductionKind::ToolInputElided { call_id, field, .. } => {
3614                resolve_tool_input_value(&r.ptr, call_id, field, sidecar_messages).map(|_| ())
3615            }
3616        };
3617        if let Err(e) = resolved {
3618            return Err(Error::new(format!(
3619                "reduction {} unresolvable against the sidecar: {e}",
3620                r.id
3621            )));
3622        }
3623    }
3624    Ok(())
3625}
3626
3627/// Rehydrate a single reduction by id (C4 `/expand <id>`; also used for
3628/// per-range `TurnsCleared` expansion). Returns the updated view and a log
3629/// with that record removed (an expanded reduction is no longer "applied").
3630///
3631/// Other placeholders in `reduced` are untouched — their bytes remain
3632/// identical.
3633pub fn invert_one_messages(
3634    reduced: &[ChatMessage],
3635    log: &ReductionLog,
3636    id: &str,
3637    sidecar_messages: &[ChatMessage],
3638) -> Result<(Vec<ChatMessage>, ReductionLog)> {
3639    let r = log
3640        .reductions
3641        .iter()
3642        .find(|r| r.id == id)
3643        .cloned()
3644        .ok_or_else(|| Error::new(format!("invert_one: no reduction with id {id} in the log")))?;
3645
3646    let mut out: Vec<ChatMessage> = reduced.to_vec();
3647    invert_one_reduction(&mut out, &r, sidecar_messages)?;
3648
3649    let mut new_log = log.clone();
3650    new_log.reductions.retain(|x| x.id != id);
3651    if !new_log.expanded.iter().any(|x| x.id == r.id) {
3652        new_log.expanded.push(r);
3653    }
3654
3655    Ok((out, new_log))
3656}
3657
3658#[cfg(test)]
3659mod tool_input_splice_tests {
3660    //! Unit tests for the TR-10 byte-surgical JSON field replacement
3661    //! primitives (private to this module) — the gotcha these exist to
3662    //! satisfy: "replace only the payload field's value, do NOT reserialize
3663    //! the whole args." Exercised directly here since they're not part of
3664    //! the public API; `tests/tool_input_elision.rs` covers the
3665    //! `project_messages`/`invert` integration level.
3666    use super::*;
3667
3668    #[test]
3669    fn finds_and_replaces_only_the_named_fields_value() {
3670        let json = r#"{"path":"src/foo.rs","content":"hello world","flag":true}"#;
3671        let (start, end) = find_top_level_string_field(json, "content").unwrap();
3672        assert_eq!(&json[start..end], "hello world");
3673
3674        let replaced = replace_top_level_string_field(json, "content", "STUB").unwrap();
3675        assert_eq!(
3676            replaced,
3677            r#"{"path":"src/foo.rs","content":"STUB","flag":true}"#
3678        );
3679        // Every other byte (key order, the `path`/`flag` values, punctuation)
3680        // is untouched — not a full reparse+reserialize.
3681        assert!(replaced.contains(r#""path":"src/foo.rs""#));
3682        assert!(replaced.contains(r#""flag":true"#));
3683    }
3684
3685    #[test]
3686    fn preserves_whitespace_and_key_order_around_the_replaced_field() {
3687        // Deliberately unusual formatting a naive reparse+reserialize would
3688        // normalize away (extra spaces, content BEFORE path).
3689        let json = "{ \"content\" : \"big\",   \"path\":\"a/b.rs\" }";
3690        let replaced = replace_top_level_string_field(json, "content", "X").unwrap();
3691        assert_eq!(replaced, "{ \"content\" : \"X\",   \"path\":\"a/b.rs\" }");
3692    }
3693
3694    #[test]
3695    fn handles_escaped_quotes_backslashes_and_unicode_in_the_value() {
3696        let original_value = "line1\nline2 \"quoted\" \\ and unicode caf\u{e9}";
3697        let json = serde_json::json!({"path": "p", "content": original_value}).to_string();
3698        let (start, end) = find_top_level_string_field(&json, "content").unwrap();
3699        // The span is the RAW (still-escaped) body; decoding it must recover
3700        // the original value exactly.
3701        let raw = format!("\"{}\"", &json[start..end]);
3702        let decoded: String = serde_json::from_str(&raw).unwrap();
3703        assert_eq!(decoded, original_value);
3704
3705        let new_value = "replacement with \"quotes\" and \\ backslash and \u{1f600}";
3706        let replaced = replace_top_level_string_field(&json, "content", new_value).unwrap();
3707        let reparsed: serde_json::Value = serde_json::from_str(&replaced).unwrap();
3708        assert_eq!(reparsed["content"], new_value);
3709        assert_eq!(reparsed["path"], "p");
3710    }
3711
3712    #[test]
3713    fn skips_nested_objects_and_arrays_in_sibling_fields() {
3714        let json = r#"{"meta":{"a":[1,2,{"b":"}}}"}]},"content":"payload","tags":["x","y"]}"#;
3715        let (start, end) = find_top_level_string_field(json, "content").unwrap();
3716        assert_eq!(&json[start..end], "payload");
3717        let replaced = replace_top_level_string_field(json, "content", "NEW").unwrap();
3718        let reparsed: serde_json::Value = serde_json::from_str(&replaced).unwrap();
3719        assert_eq!(reparsed["content"], "NEW");
3720        assert_eq!(reparsed["tags"][0], "x");
3721        assert_eq!(reparsed["meta"]["a"][2]["b"], "}}}");
3722    }
3723
3724    #[test]
3725    fn returns_none_when_field_absent_or_not_a_string_or_not_an_object() {
3726        assert_eq!(
3727            find_top_level_string_field(r#"{"path":"a"}"#, "content"),
3728            None
3729        );
3730        assert_eq!(
3731            find_top_level_string_field(r#"{"content":42}"#, "content"),
3732            None
3733        );
3734        assert_eq!(
3735            find_top_level_string_field(r#"["not","an","object"]"#, "content"),
3736            None
3737        );
3738        assert_eq!(
3739            find_top_level_string_field("not json at all", "content"),
3740            None
3741        );
3742        assert_eq!(
3743            replace_top_level_string_field(r#"{"path":"a"}"#, "content", "x"),
3744            None
3745        );
3746    }
3747}