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 ®ex {
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}