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