Skip to main content

supercode/reduce/
rehydrate.rs

1//! T12 — model-invocable rehydration/retrieval over the sidecar (SPEC.md
2//! TR-1): the two agent intrinsics `expand_reduction` and `sidecar_search`.
3//! "The missing half of A7": until this landed, only the USER could recover
4//! reduced content mid-flight (C4 `/expand`); the model itself had no way to
5//! ask for it back, which is exactly what capped how aggressive every other
6//! reducer could safely be.
7//!
8//! # The two resolution sources
9//!
10//! Both functions here are pure resolvers — no filesystem I/O, no
11//! [`crate::session::Session`] required — over TWO message slices:
12//!
13//! - `minted_view`: the messages the reductions in `log` were minted against
14//!   (a live [`crate::agent::Agent`] passes `history[1..]`; offline callers
15//!   pass a loaded `Session`'s `.messages`). Every `SidecarPtr` hash is
16//!   verified against THIS slice, because this is the slice
17//!   [`super::project_messages`] hashed when it created the reduction.
18//! - `recorded`: optionally, the recorder's full-fidelity recorded messages
19//!   (the sidecar reloaded from disk). **Defense in depth, since TR-12
20//!   (SPEC.md D6/A7 supersession, `Agent::run_loop`):** for any session
21//!   recorded under that gate — a recorder and a [`crate::reduce::ReductionPolicy`]
22//!   both installed, which is what actually triggers minting a reduction over
23//!   `history` in the first place — `Agent::cap_tool_output` is off, so
24//!   `minted_view` (`history[1..]`) already holds the same full bytes as
25//!   `recorded`, and every upgrade below is a provable no-op (`rc != minted`
26//!   fails, `minted` returned unchanged). The path still matters for two
27//!   cases where `history`/the sidecar genuinely can diverge: (1) a
28//!   **legacy** sidecar recorded before this gate existed, whose reductions
29//!   were minted from an already-`cap_tool_output`-capped `history` (`minted_view`'s
30//!   copy is a capped prefix + an honest cap notice, and the ONLY place the
31//!   full bytes still exist is the recorded copy); (2) a policy-without-recorder
32//!   agent (gate off because nothing durable backs the full bytes) that later
33//!   gains a recorder. In either case, when `recorded` is provided and its
34//!   copy of an addressed message matches the full supersession key — same
35//!   index, same role, same `tool_call_id` (present on BOTH sides; only tool
36//!   results are ever capped, and the id is unique per call), and the minted
37//!   copy is a cap-notice-bearing prefix of the recorded copy — the recorded
38//!   bytes are returned instead. The `tool_call_id` component is what makes
39//!   the key exact rather than heuristic: after `Agent::rewind_to` truncates
40//!   history (the sidecar is append-only), a re-run command can produce a
41//!   same-index, same-role tool result sharing the old run's entire kept
42//!   prefix, but it always carries a fresh `tool_call_id`, so the
43//!   old-timeline copy can never satisfy the key. Anything else falls back
44//!   to the hash-verified minted copy, so a wrong-bytes substitution is
45//!   structurally impossible: the result is always either the exact bytes
46//!   the hash was minted from, or their verified same-call full-length
47//!   superset.
48//!
49//! # No new reductions, no sentinel text
50//!
51//! This module **deliberately mints no new [`Reduction`] records and never
52//! writes [`REDUCTION_SENTINEL`] text.** An oversized [`expand_reduction`]
53//! result is just an ordinary tool result once the agent loop pushes it onto
54//! history — the EXISTING [`super::project_messages`] A7 pass re-truncates
55//! it (with a fresh, genuinely reversible stub, recorded in the log like any
56//! other reduction) once it ages out of
57//! `ReductionPolicy::protect_last_n_tool_results` on a later turn. That is
58//! what SPEC.md TR-1 dev/05 ("expand results are reduction-eligible") tests,
59//! and it is also why TR-1's "no leak-guard special case needed" holds:
60//! nothing in this module ever produces sentinel-bearing text, so a session
61//! that used these intrinsics exports through `export_session`'s existing,
62//! unconditional leak guard (A11) unmodified. `byte_range` is the proactive
63//! tool: a caller who slices a large reduction into sub-`ReductionPolicy`-
64//! cap chunks never needs the safety net at all.
65//!
66//! Note that expanding does NOT remove the reduction from the log — the
67//! stub stays in the projected view (unchanged), and the expanded content
68//! arrives as a new tool result. That is correct and deliberate: the log
69//! entry must survive so the stub keeps resolving (for a later re-expand,
70//! for `sidecar_search`, and for C4's offline tooling); contrast
71//! [`super::invert_one`], which really does splice the original back into a
72//! view and therefore removes the entry.
73//!
74//! [`REDUCTION_SENTINEL`]: super::REDUCTION_SENTINEL
75
76use serde::{Deserialize, Serialize};
77
78use super::stub::Kind;
79use super::{
80    char_boundary_floor, resolve_image_part, resolve_original_content, resolve_tool_input_value,
81    resolve_turns_range, Reduction, ReductionKind, ReductionLog,
82};
83use crate::error::{Error, Result};
84use crate::message::ChatMessage;
85
86/// The result of a successful [`expand_reduction`] call.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ExpandOutcome {
89    /// The resolved (optionally range-sliced) original content — exactly
90    /// what was asked for, byte for byte. This never truncates further on
91    /// its own; see the module doc comment for why an oversized ask is left
92    /// to the ordinary A7 pass instead, once this content lands in history.
93    pub content: String,
94    /// Total byte length of the FULL original content behind the requested
95    /// id, before any `byte_range` slicing — lets a caller judge whether (and
96    /// how) to ask for a narrower range next time.
97    pub total_bytes: usize,
98    /// The effective `[start, end)` byte range `content` was sliced to, when
99    /// the caller asked for one (clamped to `total_bytes` and to char
100    /// boundaries). `None` for a whole-content expand.
101    pub range: Option<(usize, usize)>,
102}
103
104/// Resolve the `expand_reduction(reduction_id, byte_range?)` agent intrinsic
105/// (SPEC.md TR-1 dev/01): the exact original bytes behind reduction `id` in
106/// `log`, resolved against `minted_view` (hash-verified) with `recorded`
107/// preferred for cap-diverged content — see the module doc comment for the
108/// two-source contract. With `byte_range = Some((start, end))`, just that
109/// `[start, end)` slice (end clamped to `total_bytes`, char-boundary-safe).
110///
111/// Errors, all model-recoverable:
112/// - unknown `id` → names every currently-valid id (mirrors C4's `/expand`
113///   error style);
114/// - reversed range (`start > end`) or `start` beyond the original's total
115///   bytes → names the expected `[start, end)` form and the true
116///   `total_bytes`, rather than ever silently returning empty or full
117///   content the caller didn't ask for.
118pub fn expand_reduction(
119    log: &ReductionLog,
120    minted_view: &[ChatMessage],
121    recorded: Option<&[ChatMessage]>,
122    id: &str,
123    byte_range: Option<(usize, usize)>,
124) -> Result<ExpandOutcome> {
125    let r = find_reduction(log, id)?;
126    let text = resolve_text(r, minted_view, recorded)?;
127    let total_bytes = text.len();
128    let range = match byte_range {
129        Some((s, e)) => {
130            if s > e {
131                return Err(Error::Other(format!(
132                    "expand_reduction: reversed byte_range [{s}, {e}) — expected [start, end) \
133                     with start <= end; the original is {total_bytes} bytes"
134                )));
135            }
136            if s > total_bytes {
137                return Err(Error::Other(format!(
138                    "expand_reduction: byte_range start {s} is beyond the original's \
139                     {total_bytes} bytes — expected [start, end) with start <= {total_bytes}"
140                )));
141            }
142            let cs = char_boundary_floor(&text, s);
143            let ce = char_boundary_floor(&text, e.min(total_bytes)).max(cs);
144            Some((cs, ce))
145        }
146        None => None,
147    };
148    let (start, end) = range.unwrap_or((0, total_bytes));
149    Ok(ExpandOutcome {
150        content: text[start..end].to_string(),
151        total_bytes,
152        range,
153    })
154}
155
156/// Total byte length of the original content behind reduction `id` — what
157/// [`ExpandOutcome::total_bytes`] would report — without slicing anything.
158/// Used by the agent's argument-validation error path so a malformed
159/// `byte_range` error can name the true size the caller is ranging over.
160pub fn reduction_total_bytes(
161    log: &ReductionLog,
162    minted_view: &[ChatMessage],
163    recorded: Option<&[ChatMessage]>,
164    id: &str,
165) -> Result<usize> {
166    let r = find_reduction(log, id)?;
167    Ok(resolve_text(r, minted_view, recorded)?.len())
168}
169
170/// One match [`sidecar_search`] found.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct SidecarSearchMatch {
173    /// The reduction whose hidden content this match was found in — pass
174    /// this straight to [`expand_reduction`] to see more of it.
175    pub reduction_id: String,
176    /// The stub `<kind>` token (SPEC.md D2/C2 grammar), e.g. `"tool-output"`.
177    /// Owned (rather than `&'static str`, though [`Kind::as_str`] always
178    /// hands back one) so this type round-trips through
179    /// `serde_json::from_str` — a derived `Deserialize` for a `&'static str`
180    /// field would need the deserializer's whole input to live for
181    /// `'static`, which a freshly-parsed tool-result string never does.
182    pub kind: String,
183    /// A short window of text around the match — not the whole hidden span.
184    pub snippet: String,
185}
186
187/// The complete result of a [`sidecar_search`] call. Bounded by construction
188/// (SPEC.md TR-1 fix pass): at most [`MAX_MATCHES`] snippets, each at most
189/// [`SNIPPET_MAX_BYTES`], and the whole serialized form at most
190/// [`MAX_RESULT_BYTES`] — a broad query over megabytes of hidden content can
191/// never blow the result back up to the size the reductions saved, and the
192/// truncation is honest, structured JSON (never a mid-structure byte chop).
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct SidecarSearchResult {
195    /// The matches kept (first [`MAX_MATCHES`] found, then byte-capped).
196    pub matches: Vec<SidecarSearchMatch>,
197    /// How many matches the query REALLY had, including ones dropped by the
198    /// caps — `truncated && total_matches > matches.len()` tells the model
199    /// to narrow its query.
200    pub total_matches: usize,
201    /// True when any match was dropped by [`MAX_MATCHES`]/[`MAX_RESULT_BYTES`].
202    pub truncated: bool,
203    /// Reductions in the log whose content could not currently be resolved
204    /// (e.g. their address no longer exists after `Agent::rewind_to`). They
205    /// were skipped, not fatal — one stale log entry never disables search
206    /// over the rest.
207    pub unresolvable: usize,
208}
209
210/// Bytes of context kept on each side of a match for [`SidecarSearchMatch::snippet`].
211const SNIPPET_RADIUS: usize = 80;
212
213/// Hard cap on the byte length of one [`SidecarSearchMatch::snippet`] (a
214/// regex like `z.*` can match almost an entire hidden span; the snippet must
215/// stay a snippet).
216const SNIPPET_MAX_BYTES: usize = 200;
217
218/// Hard cap on how many matches a single [`sidecar_search`] returns.
219const MAX_MATCHES: usize = 50;
220
221/// Hard cap on the serialized byte length of a [`SidecarSearchResult`].
222const MAX_RESULT_BYTES: usize = 65_536;
223
224/// Resolve the `sidecar_search(query)` agent intrinsic (SPEC.md TR-1
225/// dev/04): substring/regex search over content CURRENTLY reduced out of the
226/// view — every [`Reduction`] in `log`, restricted to its [`hidden_span`] so
227/// a still-visible portion (e.g. `ToolOutputTruncated`'s kept prefix) is
228/// never matched. "The same string visible in the live view is not
229/// double-reported" is upheld entirely from the log's own records — this
230/// still never inspects the live view: for most kinds the hidden span is a
231/// pure function of the reduction itself, and for
232/// [`ReductionKind::DuplicateOutput`] (TR-2, whose content is byte-identical
233/// to a canonical instance that is usually still fully visible) visibility
234/// is decided by whether any OTHER log record reduces the canonical's
235/// address — no per-message record there and no enclosing `TurnsCleared`
236/// range means the canonical is fully visible, so the duplicate's content is
237/// not hidden and search skips it ([`hidden_span`] returns `None`). See the
238/// module doc comment for the `minted_view`/`recorded` two-source contract.
239///
240/// An empty (or all-whitespace) `query` is an error, not a match-everything
241/// wildcard: the empty pattern compiles as a regex that matches at every
242/// position, which is never what a caller meant and used to make the result
243/// size explode.
244///
245/// `query` is tried as a case-insensitive regex first; a query that fails to
246/// compile as one (e.g. literal text with an unbalanced `[`/`(`, common in
247/// file paths or code) falls back to a plain case-insensitive substring
248/// search, so both "search for this exact snippet" and "search with a
249/// pattern" work without the caller ever needing to escape anything.
250pub fn sidecar_search(
251    log: &ReductionLog,
252    minted_view: &[ChatMessage],
253    recorded: Option<&[ChatMessage]>,
254    query: &str,
255) -> Result<SidecarSearchResult> {
256    if query.trim().is_empty() {
257        return Err(Error::Other(
258            "sidecar_search: `query` must be a non-empty substring or regex".to_string(),
259        ));
260    }
261    let regex = regex::RegexBuilder::new(query)
262        .case_insensitive(true)
263        .build()
264        .ok();
265    let lower_query = query.to_ascii_lowercase();
266
267    let mut matches = Vec::new();
268    let mut total_matches = 0usize;
269    let mut unresolvable = 0usize;
270    for r in &log.reductions {
271        // Skip (and count) anything unresolvable rather than aborting the
272        // whole search — after a rewind, one stale log entry must not
273        // disable the intrinsic for every other reduction.
274        let Ok(text) = resolve_text(r, minted_view, recorded) else {
275            unresolvable += 1;
276            continue;
277        };
278        let Some((hs, he)) = hidden_span(r, &text, log) else {
279            // ImageRedacted (not a meaningful text-search target), or a
280            // DuplicateOutput whose canonical is still fully visible (the
281            // model can already see these exact bytes in the live view).
282            continue;
283        };
284        let hidden = &text[hs..he];
285        let kind = Kind::from(&r.kind).as_str().to_string();
286
287        // `to_ascii_lowercase` (unlike `to_lowercase`) never changes byte
288        // length, so match indices stay valid against `hidden` unchanged —
289        // needed since the substring fallback path locates matches in the
290        // lowercased copy but slices snippets out of the original.
291        let positions: Vec<(usize, usize)> = match &regex {
292            Some(re) => re.find_iter(hidden).map(|m| (m.start(), m.end())).collect(),
293            None => hidden
294                .to_ascii_lowercase()
295                .match_indices(&lower_query)
296                .map(|(i, m)| (i, i + m.len()))
297                .collect(),
298        };
299
300        for (start, end) in positions {
301            total_matches += 1;
302            if matches.len() < MAX_MATCHES {
303                matches.push(SidecarSearchMatch {
304                    reduction_id: r.id.clone(),
305                    kind: kind.clone(),
306                    snippet: snippet_around(hidden, start, end),
307                });
308            }
309        }
310    }
311
312    let mut result = SidecarSearchResult {
313        truncated: total_matches > matches.len(),
314        matches,
315        total_matches,
316        unresolvable,
317    };
318    // Final serialized-size cap: drop trailing matches (valid JSON with an
319    // honest `truncated` flag, never a mid-structure byte chop) until the
320    // whole result fits. With the per-snippet and match-count caps above
321    // this loop almost never runs, but it makes the bound unconditional.
322    while serialized_len(&result) > MAX_RESULT_BYTES && !result.matches.is_empty() {
323        result.matches.pop();
324        result.truncated = true;
325    }
326    Ok(result)
327}
328
329fn serialized_len(result: &SidecarSearchResult) -> usize {
330    serde_json::to_string(result).map(|s| s.len()).unwrap_or(0)
331}
332
333/// Look up a reduction by id in `log`, erroring with a valid-id hint
334/// (mirrors `cli/src/main.rs`'s `/expand` error style) when it isn't there.
335fn find_reduction<'a>(log: &'a ReductionLog, id: &str) -> Result<&'a Reduction> {
336    log.reductions.iter().find(|r| r.id == id).ok_or_else(|| {
337        let valid: Vec<&str> = log.reductions.iter().map(|r| r.id.as_str()).collect();
338        Error::Other(format!(
339            "expand_reduction: no reduction with id `{id}` — valid ids: {}",
340            if valid.is_empty() {
341                "(none)".to_string()
342            } else {
343                valid.join(", ")
344            }
345        ))
346    })
347}
348
349/// Substitute `recorded`'s copy of the message at `index` for the
350/// already-hash-verified `minted` content — but ONLY when the full
351/// supersession key matches: same `index`, same role, same `tool_call_id`
352/// (both sides must carry one — only tool results are ever
353/// `cap_tool_output`-capped, and the id is unique per call), and `minted` is
354/// a cap-notice-bearing prefix of the recorded copy (see
355/// [`crate::agent::CAP_NOTICE_MARKER`]).
356///
357/// `tool_call_id` is what makes the key exact rather than heuristic: after
358/// `Agent::rewind_to` truncates history while the append-only sidecar keeps
359/// the old timeline, a re-run command can land a tool result at the SAME
360/// index with the SAME role whose output shares the entire kept prefix with
361/// the old run — but it always carries a NEW `tool_call_id`, so the
362/// old-timeline recorded copy can never satisfy the key. Identical content,
363/// a missing id on either side, role mismatch, or any unrelated divergence
364/// all fall back to `minted` — never a silent wrong-bytes substitution.
365fn prefer_recorded(
366    minted: String,
367    minted_msg: &ChatMessage,
368    index: usize,
369    recorded: Option<&[ChatMessage]>,
370) -> String {
371    let Some(rec) = recorded else { return minted };
372    let Some(msg) = rec.get(index) else {
373        return minted;
374    };
375    if msg.role != minted_msg.role {
376        return minted;
377    }
378    let (Some(minted_id), Some(recorded_id)) = (
379        minted_msg.tool_call_id.as_deref(),
380        msg.tool_call_id.as_deref(),
381    ) else {
382        return minted; // Only tool results are ever capped; both must carry an id.
383    };
384    if minted_id != recorded_id {
385        return minted;
386    }
387    let Some(rc) = msg.content.as_deref() else {
388        return minted;
389    };
390    if rc != minted && capped_prefix_of(&minted, rc) {
391        rc.to_string()
392    } else {
393        minted
394    }
395}
396
397/// Is `minted` a `cap_tool_output`-capped copy of `full`? True iff `minted`
398/// carries the cap-notice marker and everything before that marker is a
399/// proper prefix of `full` — the exact construction `cap_tool_output`
400/// performs (kept prefix + notice), verified byte-for-byte against the
401/// candidate full copy.
402fn capped_prefix_of(minted: &str, full: &str) -> bool {
403    let Some(pos) = minted.rfind(crate::agent::CAP_NOTICE_MARKER) else {
404        return false;
405    };
406    full.len() > pos && full.as_bytes().starts_with(&minted.as_bytes()[..pos])
407}
408
409/// The full text form of a reduction's ORIGINAL content: hash-verified
410/// against `minted_view` (the same resolve primitives `invert`/`invert_one`
411/// use, A6), then upgraded per-message to `recorded`'s full bytes where the
412/// minted copy is a verified capped prefix ([`prefer_recorded`]) — rendered
413/// to a single string for every kind so [`expand_reduction`]/
414/// [`sidecar_search`] can treat them uniformly.
415///
416/// [`ReductionKind::DuplicateOutput`] (TR-2) resolves through its OWN address
417/// exactly like [`ReductionKind::ToolOutputTruncated`]/[`ReductionKind::FileReadElided`]
418/// — never by chasing `canonical`. `minted_view` (the agent's own `history`,
419/// per the module doc comment) always retains the full original bytes at
420/// every index regardless of what any given turn's PROJECTED view showed, so
421/// self-resolution works unconditionally, including when the canonical
422/// instance has ITSELF since been truncated or cleared (dev/04) — that only
423/// ever changes what sits at the canonical's own address, never this one.
424///
425/// [`ReductionKind::OutputNormalized`] (T30/TR-4) likewise resolves through
426/// [`resolve_original_content`] — which, for this kind, means the RAW
427/// pre-normalization capture (ANSI/CR redraws and all), never the
428/// normalized/rendered text the live view shows: the sidecar only ever
429/// stores the raw bytes (A3), so byte-exact restore falls out of the exact
430/// same self-addressed pattern every other kind uses, no special-casing
431/// needed here.
432fn resolve_text(
433    r: &Reduction,
434    minted_view: &[ChatMessage],
435    recorded: Option<&[ChatMessage]>,
436) -> Result<String> {
437    match &r.kind {
438        ReductionKind::ToolOutputTruncated { .. }
439        | ReductionKind::FileReadElided { .. }
440        | ReductionKind::OutputNormalized { .. }
441        | ReductionKind::FileReadDiffed { .. }
442        | ReductionKind::DuplicateOutput { .. }
443        | ReductionKind::Superseded { .. } => {
444            let minted = resolve_original_content(&r.ptr, minted_view)?;
445            // Indexing is safe: resolve_original_content just verified the
446            // address (and role) against this very slice.
447            let minted_msg = &minted_view[r.ptr.addr.index];
448            Ok(prefer_recorded(
449                minted,
450                minted_msg,
451                r.ptr.addr.index,
452                recorded,
453            ))
454        }
455        ReductionKind::ImageRedacted { part_index } => {
456            // `cap_tool_output` never touches `content_parts`, so the minted
457            // copy IS the full copy here — no recorded upgrade needed.
458            let part = resolve_image_part(&r.ptr, *part_index, minted_view)?;
459            // The redacted content part's original `image_url.url` (a
460            // `data:` URL) IS the original bytes for expand purposes; fall
461            // back to the part's raw JSON if the shape is ever unexpected.
462            Ok(part
463                .get("image_url")
464                .and_then(|iu| iu.get("url"))
465                .and_then(|u| u.as_str())
466                .map(str::to_string)
467                .unwrap_or_else(|| part.to_string()))
468        }
469        ReductionKind::TurnsCleared { first, last, .. } => {
470            let msgs = resolve_turns_range(&r.ptr, *first, *last, minted_view)?;
471            let enriched: Vec<ChatMessage> = msgs
472                .into_iter()
473                .enumerate()
474                .map(|(offset, mut m)| {
475                    if let Some(content) = m.content.take() {
476                        let upgraded = prefer_recorded(content, &m, first + offset, recorded);
477                        m.content = Some(upgraded);
478                    }
479                    m
480                })
481                .collect();
482            Ok(render_turns(&enriched))
483        }
484        ReductionKind::ToolInputElided { call_id, field, .. } => {
485            // `cap_tool_output` only ever caps `Role::Tool` RESULT content,
486            // never a `Role::Assistant` tool_call's `arguments` — so there is
487            // no minted/recorded divergence to bridge here (the minted copy
488            // IS the full copy), the same reasoning `ImageRedacted` uses.
489            resolve_tool_input_value(&r.ptr, call_id, field, minted_view)
490        }
491    }
492}
493
494/// Render a resolved `TurnsCleared` message range as readable text for
495/// `expand_reduction`/`sidecar_search` — one role-labeled block per message,
496/// carrying EVERYTHING the cleared messages held: text content, multimodal
497/// `content_parts` (as their JSON), tool-result attribution
498/// (`name`/`tool_call_id`), and every tool call's id, name, and full
499/// arguments. Tool-call arguments matter most: content that exists ONLY
500/// there (a `write_file` call's file body, a `bash` command line) would
501/// otherwise be neither searchable nor expandable, and rescue-scenario
502/// transcripts are full of exactly that.
503fn render_turns(msgs: &[ChatMessage]) -> String {
504    let mut out = String::new();
505    for m in msgs {
506        out.push_str(&format!("--- {:?}", m.role));
507        if let Some(name) = &m.name {
508            out.push_str(&format!(" name={name}"));
509        }
510        if let Some(id) = &m.tool_call_id {
511            out.push_str(&format!(" tool_call_id={id}"));
512        }
513        out.push_str(" ---\n");
514        if let Some(c) = &m.content {
515            out.push_str(c);
516            out.push('\n');
517        }
518        if let Some(parts) = &m.content_parts {
519            for part in parts {
520                out.push_str(&serde_json::to_string(part).unwrap_or_default());
521                out.push('\n');
522            }
523        }
524        for call in m.tool_calls() {
525            out.push_str(&format!(
526                "[tool call {} {}: {}]\n",
527                call.id, call.function.name, call.function.arguments
528            ));
529        }
530    }
531    out
532}
533
534/// The byte span of `resolve_text(r, ..)`'s output that is CURRENTLY HIDDEN
535/// from the live (reduced) view — what [`sidecar_search`] is allowed to
536/// match against. [`ReductionKind::ToolOutputTruncated`]'s kept prefix
537/// (`ptr.span`'s first element) is still visible in the view, so only the
538/// remainder counts as hidden; every other kind's `ptr.span` is `None`
539/// ("whole content removed", per [`super::SidecarPtr::span`]'s doc comment),
540/// so the whole resolved text is hidden. `None` return means "not
541/// searchable": [`ReductionKind::ImageRedacted`] (a `data:` URL's base64
542/// payload is not a meaningful text-search target), and a
543/// [`ReductionKind::DuplicateOutput`] whose byte-identical canonical
544/// instance is still fully visible in the view — decided from `log` itself
545/// ([`canonical_is_reduced`]), never by inspecting the live view. When the
546/// canonical IS itself reduced (any kind, including sitting inside a
547/// `TurnsCleared` range), the duplicate's whole content counts as hidden;
548/// deliberately conservative for a `ToolOutputTruncated` canonical whose
549/// kept prefix is still partially visible.
550///
551/// [`ReductionKind::OutputNormalized`] resolves to the RAW pre-normalization
552/// bytes (ANSI codes and all, per [`resolve_text`]) — none of which are
553/// literally present in the live view (which shows the normalized text
554/// instead), so — like `FileReadElided`/`FileReadDiffed`/`TurnsCleared` — the
555/// whole resolved text counts as hidden, not just a suffix.
556///
557/// [`ReductionKind::Superseded`] (TR-6) is, unconditionally, likewise fully
558/// hidden — UNLIKE `DuplicateOutput`, its content is never required to be
559/// byte-identical to its successor (`by`), so there is no "the same bytes
560/// are already visible elsewhere" case to special-case away: whatever this
561/// reduction hides is genuinely gone from the live view, full stop.
562fn hidden_span(r: &Reduction, text: &str, log: &ReductionLog) -> Option<(usize, usize)> {
563    match &r.kind {
564        ReductionKind::ImageRedacted { .. } => None,
565        ReductionKind::ToolOutputTruncated { .. } => {
566            let kept = r.ptr.span.map(|(kept, _total)| kept).unwrap_or(0);
567            let start = char_boundary_floor(text, kept.min(text.len()));
568            Some((start, text.len()))
569        }
570        ReductionKind::DuplicateOutput { canonical, .. } => {
571            if canonical_is_reduced(canonical.index, r, log) {
572                Some((0, text.len()))
573            } else {
574                None // Canonical fully visible: these bytes are already in view.
575            }
576        }
577        ReductionKind::FileReadElided { .. }
578        | ReductionKind::FileReadDiffed { .. }
579        | ReductionKind::OutputNormalized { .. }
580        | ReductionKind::TurnsCleared { .. }
581        | ReductionKind::ToolInputElided { .. }
582        | ReductionKind::Superseded { .. } => Some((0, text.len())),
583    }
584}
585
586/// Is the message at `canonical_index` reduced (its full content NOT
587/// visible in the live view) according to `log`'s own records? True when any
588/// record other than `this` either addresses that index directly (a
589/// per-message reduction of any kind) or covers it with a `TurnsCleared`
590/// range. Pure function of the log — the live view is never consulted.
591fn canonical_is_reduced(canonical_index: usize, this: &Reduction, log: &ReductionLog) -> bool {
592    log.reductions.iter().any(|other| {
593        if other.id == this.id {
594            return false;
595        }
596        match other.kind {
597            ReductionKind::TurnsCleared { first, last, .. } => {
598                canonical_index >= first && canonical_index <= last
599            }
600            _ => other.ptr.addr.index == canonical_index,
601        }
602    })
603}
604
605/// A char-boundary-safe window of [`SNIPPET_RADIUS`] bytes on each side of
606/// `[start, end)` within `text`, hard-capped at [`SNIPPET_MAX_BYTES`] (a
607/// huge regex match must not smuggle the whole hidden span back out through
608/// its own snippet).
609fn snippet_around(text: &str, start: usize, end: usize) -> String {
610    let lo = char_boundary_floor(text, start.saturating_sub(SNIPPET_RADIUS));
611    let hi_target = (end + SNIPPET_RADIUS).min(text.len());
612    let mut hi = hi_target;
613    while hi < text.len() && !text.is_char_boundary(hi) {
614        hi += 1;
615    }
616    let window = &text[lo..hi.min(text.len())];
617    let cap = char_boundary_floor(window, SNIPPET_MAX_BYTES);
618    window[..cap].to_string()
619}
620
621// ---------------------------------------------------------------------------
622// P4c (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4" core NEW-significant item,
623// §1.10 "mid-session model switch", dep 8): reasoning-artifact filtering for
624// cross-model handoff. §1.13 names THIS FILE as dep 8's home ("cross-model
625// switches route through reasoning-artifact filtering (`reduce/rehydrate.rs`
626// — catalog §5 dep 8)") — co-located with `expand_reduction`/`sidecar_search`
627// because all three are the sidecar/cross-format-safety-relevant plumbing
628// obligation 10 (model routing) and priority 2 (emulate-to-continue) share.
629// ---------------------------------------------------------------------------
630
631/// `ChatMessage::metadata` keys that carry a source model's private
632/// reasoning/thinking payload — populated today by `Session`'s foreign-
633/// format importers:
634///
635/// - Claude Code / Codex legacy singular fields (`session.rs`'s
636///   `push_claude_assistant`/Codex `reasoning` `response_item` handling):
637///   `"thinking"`/`"thinking_signature"`/`"redacted_thinking"`/
638///   `"reasoning"`/`"reasoning_content"`/`"reasoning_encrypted"`.
639/// - `"thinking_blocks"` — the Claude Code importer's exact per-block replay
640///   list (`session.rs:3475-3480`, `push_claude_assistant`): every
641///   `thinking`/`redacted_thinking` content block preserved SEPARATELY, in
642///   order, each with its own signature/data, as a serialized JSON array.
643///   REVIEW FINDING (P4c dep 8, MEDIUM, proven): the Claude Code EXPORTER
644///   (`session.rs`'s `to_claude_code_jsonl`, ~5654-5676) PREFERS this key
645///   over the legacy singular fields whenever present — so leaving it out of
646///   this list let a full signed chain-of-thought survive `switch_model`
647///   untouched and get re-emitted, re-attributed to model-B, on the very
648///   next Claude Code export. Added here to close that hole.
649/// - `"pi_thought_signature"` — pi's per-`toolCall`-block `thoughtSignature`
650///   (Google-provider reasoning-continuity token, `session.rs:4248-4249`
651///   import / `session.rs:7418-7419` export as `pi_assistant_content_value`
652///   re-emits it onto every `toolCall` block, independent of whether any
653///   `thinking`/`thinking_blocks` key is even present on the message).
654///   Audited in alongside the fix above: like `redacted_thinking`, its
655///   payload is opaque/encrypted rather than literal text, but this crate
656///   already treats "opaque encrypted reasoning artifact" as reasoning-
657///   bearing for `redacted_thinking` — `pi_thought_signature` is the same
658///   class of thing (Google's opaque encoded reasoning trace tied to a tool
659///   call), just pi-namespaced (`docs/interop/research/pi-fields.md:161`
660///   groups it under "provider replay signatures" alongside
661///   `thinking_signature`). Deliberately NOT added: `pi_text_signature`
662///   (`session.rs:4219-4224`/`4271-4272`, OpenAI Responses replay-continuity
663///   id for a `text` block whose content is already fully exposed via
664///   `msg.content` — pi-fields.md's own per-field note calls it "replay-
665///   continuity residue", not a reasoning payload) and `pi_thinking_redacted`
666///   (`session.rs:4267-4269`, a bare boolean flag with no payload of its
667///   own — and inert regardless, since `pi_assistant_content_value` only
668///   ever reads it from inside the `if let Some(thinking) = ...` arm gated
669///   on the now-stripped `"thinking"` key, so it can never reach an export
670///   on its own). Stripping either would be over-stripping non-reasoning
671///   metadata for no leak-closing benefit.
672///
673/// `metadata` itself is never serialized onto the wire (`ChatMessage`'s
674/// custom `Serialize` impl omits it — see `message.rs`), so this list is a
675/// DEFENSE-IN-DEPTH filter, not the primary leak-prevention mechanism: the
676/// primary one is that `metadata` never reaches a provider request at all,
677/// regardless of this function. What this filter actually guarantees is the
678/// OBSERVABLE contract dep 8 asks for — a post-switch inspection of
679/// `history` (the in-memory session state, sidecar exports, `/export`, a
680/// future translator emitting this session under another harness's format)
681/// never shows model-A's reasoning attributed to a conversation now being
682/// driven by model-B.
683pub const REASONING_METADATA_KEYS: &[&str] = &[
684    "thinking",
685    "thinking_signature",
686    "redacted_thinking",
687    "reasoning",
688    "reasoning_content",
689    "reasoning_encrypted",
690    "thinking_blocks",
691    "pi_thought_signature",
692];
693
694/// `content_parts` block `"type"` values that carry a reasoning payload as
695/// model-VISIBLE content (as opposed to the metadata-only keys above) —
696/// e.g. a future/foreign provider that echoes `{"type":"thinking",...}` or
697/// `{"type":"reasoning",...}` blocks back into a message's content array
698/// for continuation. None of supercode's own `content_parts` constructors
699/// (`ChatMessage::user_with_images`/`tool_result_with_image`) ever produce
700/// these types today, so this branch is defensive breadth against future/
701/// foreign content rather than something exercised by supercode's own
702/// native loop yet.
703pub const REASONING_CONTENT_PART_TYPES: &[&str] = &["thinking", "reasoning", "redacted_thinking"];
704
705/// Mid-session model switch (§1.10, dep 8): strip every reasoning artifact
706/// out of `history` in place — both the metadata keys
707/// ([`REASONING_METADATA_KEYS`]) and any `content_parts` blocks whose
708/// `"type"` is in [`REASONING_CONTENT_PART_TYPES`] — so model-A's reasoning
709/// never reaches model-B's context on the next request built from this
710/// history. Returns the count of MESSAGES actually touched (had at least
711/// one metadata key removed and/or at least one content part removed) —
712/// `Agent::switch_model` records this in the persisted `model_change`
713/// entry (`ModelChangeRecord::reasoning_artifacts_filtered`).
714///
715/// Pure and total: never panics, a message with nothing to filter is left
716/// byte-identical (down to `content_parts` ordering of the parts that
717/// survive), and calling this with nothing to filter (the common case —
718/// supercode's own live loop never populates these keys) is a cheap no-op
719/// scan, not a rebuild.
720pub fn filter_reasoning_artifacts(history: &mut [ChatMessage]) -> usize {
721    let mut touched = 0usize;
722    for msg in history.iter_mut() {
723        let mut this_touched = false;
724        for key in REASONING_METADATA_KEYS {
725            if msg.metadata.remove(*key).is_some() {
726                this_touched = true;
727            }
728        }
729        if let Some(parts) = msg.content_parts.as_mut() {
730            let before = parts.len();
731            parts.retain(|p| {
732                p.get("type")
733                    .and_then(|t| t.as_str())
734                    .map(|t| !REASONING_CONTENT_PART_TYPES.contains(&t))
735                    .unwrap_or(true)
736            });
737            if parts.len() != before {
738                this_touched = true;
739            }
740        }
741        if this_touched {
742            touched += 1;
743        }
744    }
745    touched
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use crate::reduce::{content_hash, make_id, stub, MessageAddr, SidecarPtr};
752    use crate::Role;
753
754    fn tool_output_reduction(
755        id_ordinal: usize,
756        addr_index: usize,
757        original: &str,
758        kept: usize,
759    ) -> Reduction {
760        let hash = content_hash(original.as_bytes());
761        let id = make_id(id_ordinal, &hash);
762        let summary = format!(
763            "t output truncated {}B, kept {kept}B — full output in session sidecar",
764            original.len()
765        );
766        Reduction {
767            id: id.clone(),
768            kind: ReductionKind::ToolOutputTruncated {
769                original_bytes: original.len(),
770                kept_bytes: kept,
771            },
772            ptr: SidecarPtr {
773                addr: MessageAddr {
774                    index: addr_index,
775                    role: Role::Tool,
776                },
777                span: Some((kept, original.len())),
778                content_hash: hash,
779            },
780            placeholder: stub::format(stub::Kind::ToolOutput, &id, &summary),
781        }
782    }
783
784    fn one_reduction_log(original: &str, kept: usize) -> (Vec<ChatMessage>, ReductionLog) {
785        let msg = ChatMessage::tool_result("c1", "bash", original.to_string());
786        let r = tool_output_reduction(0, 0, original, kept);
787        (
788            vec![msg],
789            ReductionLog {
790                reductions: vec![r],
791                expanded: vec![],
792                read_log: vec![],
793                attribution: None,
794            },
795        )
796    }
797
798    #[test]
799    fn expand_returns_exact_bytes_and_ranges() {
800        let original = "0123456789abcdefghij";
801        let (minted, log) = one_reduction_log(original, 10);
802        let id = log.reductions[0].id.clone();
803
804        let whole = expand_reduction(&log, &minted, None, &id, None).unwrap();
805        assert_eq!(whole.content, original);
806        assert_eq!(whole.total_bytes, original.len());
807        assert_eq!(whole.range, None);
808
809        let ranged = expand_reduction(&log, &minted, None, &id, Some((10, 15))).unwrap();
810        assert_eq!(ranged.content, "abcde");
811        assert_eq!(ranged.range, Some((10, 15)));
812        assert_eq!(ranged.total_bytes, original.len());
813
814        // End beyond the total clamps (the model may not know the exact
815        // size); start beyond it errors — see the validation test below.
816        let clamped = expand_reduction(&log, &minted, None, &id, Some((15, 10_000))).unwrap();
817        assert_eq!(clamped.content, &original[15..]);
818        assert_eq!(clamped.range, Some((15, original.len())));
819    }
820
821    #[test]
822    fn expand_rejects_reversed_and_out_of_bounds_ranges() {
823        let original = "0123456789";
824        let (minted, log) = one_reduction_log(original, 4);
825        let id = log.reductions[0].id.clone();
826
827        let reversed = expand_reduction(&log, &minted, None, &id, Some((100, 5))).unwrap_err();
828        let msg = reversed.to_string();
829        assert!(msg.contains("reversed"), "{msg}");
830        assert!(msg.contains("[start, end)"), "{msg}");
831        assert!(msg.contains("10 bytes"), "{msg}");
832
833        let oob = expand_reduction(&log, &minted, None, &id, Some((11, 20))).unwrap_err();
834        let msg = oob.to_string();
835        assert!(msg.contains("beyond"), "{msg}");
836        assert!(msg.contains("10"), "{msg}");
837    }
838
839    #[test]
840    fn expand_unknown_id_errors_with_valid_id_hint() {
841        let log = ReductionLog {
842            reductions: vec![tool_output_reduction(0, 0, "abc", 1)],
843            expanded: vec![],
844            read_log: vec![],
845            attribution: None,
846        };
847        let err = expand_reduction(&log, &[], None, "r9999-dead", None).unwrap_err();
848        assert!(err.to_string().contains("r9999-dead"));
849        assert!(err.to_string().contains(&log.reductions[0].id));
850    }
851
852    #[test]
853    fn recorded_copy_supersedes_a_capped_minted_copy() {
854        // The recorded (sidecar) copy holds the full bytes; the minted
855        // (history) copy is cap_tool_output's prefix + notice, and the
856        // reduction hash was minted from THAT copy.
857        let full = "F".repeat(1000);
858        let capped = format!(
859            "{}{}{} bytes total, showing first 600; full output in session sidecar]",
860            &full[..600],
861            crate::agent::CAP_NOTICE_MARKER,
862            1000
863        );
864        let (minted, log) = one_reduction_log(&capped, 100);
865        let id = log.reductions[0].id.clone();
866        let recorded = vec![ChatMessage::tool_result("c1", "bash", full.clone())];
867
868        // With the recorded source: the FULL bytes, not the capped copy.
869        let out = expand_reduction(&log, &minted, Some(&recorded), &id, None).unwrap();
870        assert_eq!(out.content, full);
871        assert_eq!(out.total_bytes, 1000);
872
873        // Without it: the verified minted copy (whose embedded notice is
874        // honest about the true total) — never an error, never wrong bytes.
875        let out = expand_reduction(&log, &minted, None, &id, None).unwrap();
876        assert_eq!(out.content, capped);
877
878        // A recorded copy that is NOT a capped superset (index drift after
879        // a rewind) is ignored in favor of the verified minted copy.
880        let drifted = vec![ChatMessage::tool_result("cX", "bash", "unrelated")];
881        let out = expand_reduction(&log, &minted, Some(&drifted), &id, None).unwrap();
882        assert_eq!(out.content, capped);
883    }
884
885    #[test]
886    fn recorded_copy_with_different_tool_call_id_never_supersedes() {
887        // The rewind-and-re-run drift scenario: `rewind_to` truncated
888        // history (the sidecar is append-only), then the model re-ran a
889        // command at the SAME history index whose output shares the ENTIRE
890        // kept prefix with the pre-rewind run but diverges after. Same
891        // index, same role, and the old-timeline full copy really does
892        // extend the new capped copy's kept prefix — the only component of
893        // the supersession key telling the two apart is the per-call-unique
894        // `tool_call_id`.
895        let old_full = format!("{}OLD-TIMELINE-TAIL", "S".repeat(600));
896        let capped = format!(
897            "{}{}{} bytes total, showing first 600; full output in session sidecar]",
898            &old_full[..600], // the shared kept prefix
899            crate::agent::CAP_NOTICE_MARKER,
900            900
901        );
902
903        // The minted (post-rewind) tool result carries the NEW call id; the
904        // recorded (old-timeline) copy at the same index carries the OLD one.
905        let minted = vec![ChatMessage::tool_result("c-new", "bash", capped.clone())];
906        let r = tool_output_reduction(0, 0, &capped, 100);
907        let id = r.id.clone();
908        let log = ReductionLog {
909            reductions: vec![r],
910            expanded: vec![],
911            read_log: vec![],
912            attribution: None,
913        };
914        let recorded = vec![ChatMessage::tool_result("c-old", "bash", old_full.clone())];
915
916        // The old timeline's tail must NEVER come back as this run's hidden
917        // content: the id mismatch fails the key, falling back to the
918        // verified minted copy.
919        let out = expand_reduction(&log, &minted, Some(&recorded), &id, None).unwrap();
920        assert_eq!(
921            out.content, capped,
922            "an old-timeline copy with a different tool_call_id must never supersede"
923        );
924        assert!(!out.content.contains("OLD-TIMELINE-TAIL"));
925
926        // Control: the identical setup with MATCHING ids does supersede —
927        // proving the assertion above fails exactly when the id check is
928        // removed, not for some incidental reason.
929        let same_id = vec![ChatMessage::tool_result("c-new", "bash", old_full.clone())];
930        let out = expand_reduction(&log, &minted, Some(&same_id), &id, None).unwrap();
931        assert_eq!(out.content, old_full);
932    }
933
934    #[test]
935    fn search_finds_hidden_but_not_kept_prefix() {
936        let original = "KEPTPREFIX-needle-is-here-in-the-hidden-tail";
937        let kept = "KEPTPREFIX".len();
938        let (minted, log) = one_reduction_log(original, kept);
939        let id = log.reductions[0].id.clone();
940
941        let hits = sidecar_search(&log, &minted, None, "needle").unwrap();
942        assert_eq!(hits.matches.len(), 1);
943        assert_eq!(hits.total_matches, 1);
944        assert!(!hits.truncated);
945        assert_eq!(hits.unresolvable, 0);
946        assert_eq!(hits.matches[0].reduction_id, id);
947        assert_eq!(hits.matches[0].kind, "tool-output");
948        assert!(hits.matches[0].snippet.contains("needle"));
949
950        // A query only present in the still-visible kept prefix is not
951        // reported at all.
952        let none = sidecar_search(&log, &minted, None, "KEPTPREFIX").unwrap();
953        assert!(
954            none.matches.is_empty() && none.total_matches == 0,
955            "kept prefix must not be searchable: {none:?}"
956        );
957    }
958
959    #[test]
960    fn search_regex_and_literal_fallback() {
961        let original = "error: file not found at /a/b/c.rs [line 42";
962        let (minted, log) = one_reduction_log(original, 0);
963
964        // Regex query.
965        let hits = sidecar_search(&log, &minted, None, r"line \d+").unwrap();
966        assert_eq!(hits.matches.len(), 1);
967
968        // Literal text with an unbalanced `[`: fails to compile as a regex
969        // (unterminated character class), falls back to plain substring.
970        let hits2 = sidecar_search(&log, &minted, None, "c.rs [line").unwrap();
971        assert_eq!(hits2.matches.len(), 1);
972        assert!(hits2.matches[0].snippet.contains("c.rs [line"));
973    }
974
975    #[test]
976    fn search_rejects_empty_query_and_caps_result_size() {
977        let original = "z".repeat(50_000);
978        let (minted, log) = one_reduction_log(&original, 0);
979
980        // Empty/whitespace queries are errors, not match-everything.
981        assert!(sidecar_search(&log, &minted, None, "").is_err());
982        assert!(sidecar_search(&log, &minted, None, "   ").is_err());
983
984        // 50,000 single-char matches: capped at MAX_MATCHES, honestly
985        // flagged, with the true total reported and the serialized form
986        // bounded and valid JSON.
987        let hits = sidecar_search(&log, &minted, None, "z").unwrap();
988        assert_eq!(hits.matches.len(), MAX_MATCHES);
989        assert_eq!(hits.total_matches, 50_000);
990        assert!(hits.truncated);
991        let serialized = serde_json::to_string(&hits).unwrap();
992        assert!(
993            serialized.len() <= MAX_RESULT_BYTES,
994            "serialized result must stay under the byte cap: {} bytes",
995            serialized.len()
996        );
997        let reparsed: SidecarSearchResult = serde_json::from_str(&serialized).unwrap();
998        assert_eq!(reparsed, hits);
999
1000        // A pathological regex matching (nearly) the whole span: the
1001        // snippet stays a snippet.
1002        let hits = sidecar_search(&log, &minted, None, "z.*").unwrap();
1003        assert!(hits.matches[0].snippet.len() <= SNIPPET_MAX_BYTES);
1004        assert!(serialized_len(&hits) <= MAX_RESULT_BYTES);
1005    }
1006
1007    #[test]
1008    fn search_skips_unresolvable_reductions_instead_of_aborting() {
1009        let original = "the needle is in here";
1010        let (minted, log) = one_reduction_log(original, 0);
1011        // A second reduction whose address does not exist in the view (the
1012        // post-rewind shape).
1013        let mut log = log;
1014        log.reductions.push(tool_output_reduction(1, 99, "gone", 0));
1015
1016        let hits = sidecar_search(&log, &minted, None, "needle").unwrap();
1017        assert_eq!(hits.matches.len(), 1, "{hits:?}");
1018        assert_eq!(hits.unresolvable, 1);
1019    }
1020
1021    #[test]
1022    fn turns_cleared_render_carries_tool_call_payloads() {
1023        use crate::message::{FunctionCall, ToolCall};
1024
1025        let call = ChatMessage {
1026            role: Role::Assistant,
1027            content: None,
1028            content_parts: None,
1029            tool_calls: Some(vec![ToolCall {
1030                id: "w1".to_string(),
1031                kind: "function".to_string(),
1032                function: FunctionCall {
1033                    name: "write_file".to_string(),
1034                    arguments: serde_json::json!({
1035                        "path": "notes.txt",
1036                        "content": "ARGS-ONLY-PAYLOAD-77"
1037                    })
1038                    .to_string(),
1039                },
1040            }]),
1041            tool_call_id: None,
1042            name: None,
1043            metadata: Default::default(),
1044        };
1045        let result = ChatMessage::tool_result("w1", "write_file", "ok");
1046        let rendered = render_turns(&[call, result]);
1047        assert!(rendered.contains("ARGS-ONLY-PAYLOAD-77"), "{rendered}");
1048        assert!(rendered.contains("write_file"), "{rendered}");
1049        assert!(rendered.contains("w1"), "{rendered}");
1050        assert!(rendered.contains("tool_call_id=w1"), "{rendered}");
1051    }
1052
1053    // ---- P4c: filter_reasoning_artifacts (S1.10 dep 8) --------------------
1054
1055    #[test]
1056    fn filter_reasoning_artifacts_strips_every_documented_metadata_key() {
1057        let mut msg = ChatMessage::assistant("the answer");
1058        for key in REASONING_METADATA_KEYS {
1059            msg.metadata
1060                .insert(key.to_string(), "secret-cot".to_string());
1061        }
1062        msg.metadata
1063            .insert("unrelated".to_string(), "kept".to_string());
1064        let mut history = vec![msg];
1065        let touched = filter_reasoning_artifacts(&mut history);
1066        assert_eq!(touched, 1);
1067        for key in REASONING_METADATA_KEYS {
1068            assert!(
1069                !history[0].metadata.contains_key(*key),
1070                "{key} should have been stripped"
1071            );
1072        }
1073        assert_eq!(
1074            history[0].metadata.get("unrelated").map(String::as_str),
1075            Some("kept"),
1076            "non-reasoning metadata must survive untouched"
1077        );
1078    }
1079
1080    #[test]
1081    fn filter_reasoning_artifacts_strips_reasoning_content_parts_keeps_others() {
1082        let mut msg = ChatMessage::assistant("");
1083        msg.content_parts = Some(vec![
1084            serde_json::json!({"type": "text", "text": "visible"}),
1085            serde_json::json!({"type": "thinking", "text": "model-A's private CoT"}),
1086            serde_json::json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}),
1087        ]);
1088        let mut history = vec![msg];
1089        let touched = filter_reasoning_artifacts(&mut history);
1090        assert_eq!(touched, 1);
1091        let parts = history[0].content_parts.as_ref().unwrap();
1092        assert_eq!(parts.len(), 2, "{parts:?}");
1093        assert!(parts.iter().all(|p| p["type"] != "thinking"), "{parts:?}");
1094        assert!(parts.iter().any(|p| p["type"] == "text"), "{parts:?}");
1095        assert!(parts.iter().any(|p| p["type"] == "image_url"), "{parts:?}");
1096    }
1097
1098    /// Default/happy path: a message with nothing to filter is left
1099    /// byte-identical — not touched, not counted.
1100    #[test]
1101    fn filter_reasoning_artifacts_leaves_clean_messages_untouched() {
1102        let mut history = vec![
1103            ChatMessage::user("hello"),
1104            ChatMessage::assistant("hi there"),
1105            ChatMessage::tool_result("call_1", "read_file", "file contents"),
1106        ];
1107        let before = history.clone();
1108        let touched = filter_reasoning_artifacts(&mut history);
1109        assert_eq!(touched, 0);
1110        for (a, b) in history.iter().zip(before.iter()) {
1111            assert_eq!(a.content, b.content);
1112            assert_eq!(a.metadata, b.metadata);
1113        }
1114    }
1115
1116    /// Only messages that actually carry a reasoning artifact count toward
1117    /// the returned total — the boundary between "filtered" and "just
1118    /// passed through".
1119    #[test]
1120    fn filter_reasoning_artifacts_only_counts_actually_touched_messages() {
1121        let mut clean = ChatMessage::assistant("clean turn");
1122        let mut dirty = ChatMessage::assistant("dirty turn");
1123        dirty
1124            .metadata
1125            .insert("thinking".to_string(), "secret".to_string());
1126        let mut history = vec![clean.clone(), dirty];
1127        let touched = filter_reasoning_artifacts(&mut history);
1128        assert_eq!(touched, 1);
1129        clean.metadata.clear();
1130        assert_eq!(history[0].content, clean.content);
1131        assert!(!history[1].metadata.contains_key("thinking"));
1132    }
1133
1134    /// P4c-review (MEDIUM, proven): `"thinking_blocks"` — the Claude Code
1135    /// importer's per-block replay list the CC exporter PREFERS over the
1136    /// legacy singular fields (`session.rs:3475-3480` import,
1137    /// `session.rs:5654-5676` export) — and `"pi_thought_signature"` — pi's
1138    /// per-`toolCall` Google reasoning-continuity token
1139    /// (`session.rs:4248-4249` import, `session.rs:7418-7419` export,
1140    /// re-emitted independent of any `thinking`/`thinking_blocks` key —
1141    /// were the two additional reasoning-bearing metadata keys the audit
1142    /// found missing from the strip list. Explicit, standalone proof (on
1143    /// top of the loop over the whole list above) that both are actually
1144    /// removed, not just declared.
1145    #[test]
1146    fn filter_reasoning_artifacts_strips_thinking_blocks_and_pi_thought_signature() {
1147        assert!(REASONING_METADATA_KEYS.contains(&"thinking_blocks"));
1148        assert!(REASONING_METADATA_KEYS.contains(&"pi_thought_signature"));
1149
1150        let mut msg = ChatMessage::assistant("here's my answer");
1151        msg.metadata.insert(
1152            "thinking_blocks".to_string(),
1153            serde_json::json!([{"type": "thinking", "thinking": "model-A's private CoT", "signature": "sig-abc"}]).to_string(),
1154        );
1155        msg.metadata.insert(
1156            "pi_thought_signature".to_string(),
1157            "google-opaque-reasoning-continuity-token".to_string(),
1158        );
1159        let mut history = vec![msg];
1160        let touched = filter_reasoning_artifacts(&mut history);
1161        assert_eq!(touched, 1);
1162        assert!(!history[0].metadata.contains_key("thinking_blocks"));
1163        assert!(!history[0].metadata.contains_key("pi_thought_signature"));
1164    }
1165
1166    /// P4c-review audit: `"pi_text_signature"` (OpenAI Responses replay-
1167    /// continuity id for a `text` block whose content is already exposed
1168    /// via `msg.content` — not a reasoning payload) and
1169    /// `"pi_thinking_redacted"` (a bare boolean flag, no payload, and inert
1170    /// on export anyway since `pi_assistant_content_value` only reads it
1171    /// from inside the `if let Some(thinking) = ...` arm gated on the
1172    /// already-stripped `"thinking"` key) are deliberately NOT reasoning-
1173    /// bearing — the filter must not over-strip them.
1174    #[test]
1175    fn filter_reasoning_artifacts_does_not_over_strip_non_reasoning_pi_keys() {
1176        assert!(!REASONING_METADATA_KEYS.contains(&"pi_text_signature"));
1177        assert!(!REASONING_METADATA_KEYS.contains(&"pi_thinking_redacted"));
1178
1179        let mut msg = ChatMessage::assistant("here's my answer");
1180        msg.metadata
1181            .insert("pi_text_signature".to_string(), "replay-id-123".to_string());
1182        msg.metadata
1183            .insert("pi_thinking_redacted".to_string(), "true".to_string());
1184        let mut history = vec![msg];
1185        filter_reasoning_artifacts(&mut history);
1186        assert_eq!(
1187            history[0]
1188                .metadata
1189                .get("pi_text_signature")
1190                .map(String::as_str),
1191            Some("replay-id-123")
1192        );
1193        assert_eq!(
1194            history[0]
1195                .metadata
1196                .get("pi_thinking_redacted")
1197                .map(String::as_str),
1198            Some("true")
1199        );
1200    }
1201}