use serde::{Deserialize, Serialize};
use super::stub::Kind;
use super::{
char_boundary_floor, resolve_image_part, resolve_original_content, resolve_tool_input_value,
resolve_turns_range, Reduction, ReductionKind, ReductionLog,
};
use crate::{ReductionError as Error, Result};
use supercode_interchange::ChatMessage;
pub const CAP_NOTICE_MARKER: &str = "\n\n[supercode: tool output truncated — ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpandOutcome {
pub content: String,
pub total_bytes: usize,
pub range: Option<(usize, usize)>,
}
pub fn expand_reduction(
log: &ReductionLog,
minted_view: &[ChatMessage],
recorded: Option<&[ChatMessage]>,
id: &str,
byte_range: Option<(usize, usize)>,
) -> Result<ExpandOutcome> {
let r = find_reduction(log, id)?;
let text = resolve_text(r, minted_view, recorded)?;
let total_bytes = text.len();
let range = match byte_range {
Some((s, e)) => {
if s > e {
return Err(Error::new(format!(
"expand_reduction: reversed byte_range [{s}, {e}) — expected [start, end) \
with start <= end; the original is {total_bytes} bytes"
)));
}
if s > total_bytes {
return Err(Error::new(format!(
"expand_reduction: byte_range start {s} is beyond the original's \
{total_bytes} bytes — expected [start, end) with start <= {total_bytes}"
)));
}
let cs = char_boundary_floor(&text, s);
let ce = char_boundary_floor(&text, e.min(total_bytes)).max(cs);
Some((cs, ce))
}
None => None,
};
let (start, end) = range.unwrap_or((0, total_bytes));
Ok(ExpandOutcome {
content: text[start..end].to_string(),
total_bytes,
range,
})
}
pub fn reduction_total_bytes(
log: &ReductionLog,
minted_view: &[ChatMessage],
recorded: Option<&[ChatMessage]>,
id: &str,
) -> Result<usize> {
let r = find_reduction(log, id)?;
Ok(resolve_text(r, minted_view, recorded)?.len())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SidecarSearchMatch {
pub reduction_id: String,
pub kind: String,
pub snippet: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SidecarSearchResult {
pub matches: Vec<SidecarSearchMatch>,
pub total_matches: usize,
pub truncated: bool,
pub unresolvable: usize,
}
const SNIPPET_RADIUS: usize = 80;
const SNIPPET_MAX_BYTES: usize = 200;
const MAX_MATCHES: usize = 50;
const MAX_RESULT_BYTES: usize = 65_536;
pub fn sidecar_search(
log: &ReductionLog,
minted_view: &[ChatMessage],
recorded: Option<&[ChatMessage]>,
query: &str,
) -> Result<SidecarSearchResult> {
if query.trim().is_empty() {
return Err(Error::new(
"sidecar_search: `query` must be a non-empty substring or regex".to_string(),
));
}
let regex = regex::RegexBuilder::new(query)
.case_insensitive(true)
.build()
.ok();
let lower_query = query.to_ascii_lowercase();
let mut matches = Vec::new();
let mut total_matches = 0usize;
let mut unresolvable = 0usize;
for r in &log.reductions {
let Ok(text) = resolve_text(r, minted_view, recorded) else {
unresolvable += 1;
continue;
};
let Some((hs, he)) = hidden_span(r, &text, log) else {
continue;
};
let hidden = &text[hs..he];
let kind = Kind::from(&r.kind).as_str().to_string();
let positions: Vec<(usize, usize)> = match ®ex {
Some(re) => re.find_iter(hidden).map(|m| (m.start(), m.end())).collect(),
None => hidden
.to_ascii_lowercase()
.match_indices(&lower_query)
.map(|(i, m)| (i, i + m.len()))
.collect(),
};
for (start, end) in positions {
total_matches += 1;
if matches.len() < MAX_MATCHES {
matches.push(SidecarSearchMatch {
reduction_id: r.id.clone(),
kind: kind.clone(),
snippet: snippet_around(hidden, start, end),
});
}
}
}
let mut result = SidecarSearchResult {
truncated: total_matches > matches.len(),
matches,
total_matches,
unresolvable,
};
while serialized_len(&result) > MAX_RESULT_BYTES && !result.matches.is_empty() {
result.matches.pop();
result.truncated = true;
}
Ok(result)
}
fn serialized_len(result: &SidecarSearchResult) -> usize {
serde_json::to_string(result).map(|s| s.len()).unwrap_or(0)
}
fn find_reduction<'a>(log: &'a ReductionLog, id: &str) -> Result<&'a Reduction> {
log.reductions.iter().find(|r| r.id == id).ok_or_else(|| {
let valid: Vec<&str> = log.reductions.iter().map(|r| r.id.as_str()).collect();
Error::new(format!(
"expand_reduction: no reduction with id `{id}` — valid ids: {}",
if valid.is_empty() {
"(none)".to_string()
} else {
valid.join(", ")
}
))
})
}
fn prefer_recorded(
minted: String,
minted_msg: &ChatMessage,
index: usize,
recorded: Option<&[ChatMessage]>,
) -> String {
let Some(rec) = recorded else { return minted };
let Some(msg) = rec.get(index) else {
return minted;
};
if msg.role != minted_msg.role {
return minted;
}
let (Some(minted_id), Some(recorded_id)) = (
minted_msg.tool_call_id.as_deref(),
msg.tool_call_id.as_deref(),
) else {
return minted; };
if minted_id != recorded_id {
return minted;
}
let Some(rc) = msg.content.as_deref() else {
return minted;
};
if rc != minted && capped_prefix_of(&minted, rc) {
rc.to_string()
} else {
minted
}
}
fn capped_prefix_of(minted: &str, full: &str) -> bool {
let Some(pos) = minted.rfind(CAP_NOTICE_MARKER) else {
return false;
};
full.len() > pos && full.as_bytes().starts_with(&minted.as_bytes()[..pos])
}
fn resolve_text(
r: &Reduction,
minted_view: &[ChatMessage],
recorded: Option<&[ChatMessage]>,
) -> Result<String> {
match &r.kind {
ReductionKind::ToolOutputTruncated { .. }
| ReductionKind::FileReadElided { .. }
| ReductionKind::OutputNormalized { .. }
| ReductionKind::FileReadDiffed { .. }
| ReductionKind::DuplicateOutput { .. }
| ReductionKind::Superseded { .. } => {
let minted = resolve_original_content(&r.ptr, minted_view)?;
let minted_msg = &minted_view[r.ptr.addr.index];
Ok(prefer_recorded(
minted,
minted_msg,
r.ptr.addr.index,
recorded,
))
}
ReductionKind::ImageRedacted { part_index } => {
let part = resolve_image_part(&r.ptr, *part_index, minted_view)?;
Ok(part
.get("image_url")
.and_then(|iu| iu.get("url"))
.and_then(|u| u.as_str())
.map(str::to_string)
.unwrap_or_else(|| part.to_string()))
}
ReductionKind::TurnsCleared { first, last, .. } => {
let msgs = resolve_turns_range(&r.ptr, *first, *last, minted_view)?;
let enriched: Vec<ChatMessage> = msgs
.into_iter()
.enumerate()
.map(|(offset, mut m)| {
if let Some(content) = m.content.take() {
let upgraded = prefer_recorded(content, &m, first + offset, recorded);
m.content = Some(upgraded);
}
m
})
.collect();
Ok(render_turns(&enriched))
}
ReductionKind::ToolInputElided { call_id, field, .. } => {
Ok(resolve_tool_input_value(
&r.ptr,
call_id,
field,
minted_view,
)?)
}
}
}
fn render_turns(msgs: &[ChatMessage]) -> String {
let mut out = String::new();
for m in msgs {
out.push_str(&format!("--- {:?}", m.role));
if let Some(name) = &m.name {
out.push_str(&format!(" name={name}"));
}
if let Some(id) = &m.tool_call_id {
out.push_str(&format!(" tool_call_id={id}"));
}
out.push_str(" ---\n");
if let Some(c) = &m.content {
out.push_str(c);
out.push('\n');
}
if let Some(parts) = &m.content_parts {
for part in parts {
out.push_str(&serde_json::to_string(part).unwrap_or_default());
out.push('\n');
}
}
for call in m.tool_calls() {
out.push_str(&format!(
"[tool call {} {}: {}]\n",
call.id, call.function.name, call.function.arguments
));
}
}
out
}
fn hidden_span(r: &Reduction, text: &str, log: &ReductionLog) -> Option<(usize, usize)> {
match &r.kind {
ReductionKind::ImageRedacted { .. } => None,
ReductionKind::ToolOutputTruncated { .. } => {
let kept = r.ptr.span.map(|(kept, _total)| kept).unwrap_or(0);
let start = char_boundary_floor(text, kept.min(text.len()));
Some((start, text.len()))
}
ReductionKind::DuplicateOutput { canonical, .. } => {
if canonical_is_reduced(canonical.index, r, log) {
Some((0, text.len()))
} else {
None }
}
ReductionKind::FileReadElided { .. }
| ReductionKind::FileReadDiffed { .. }
| ReductionKind::OutputNormalized { .. }
| ReductionKind::TurnsCleared { .. }
| ReductionKind::ToolInputElided { .. }
| ReductionKind::Superseded { .. } => Some((0, text.len())),
}
}
fn canonical_is_reduced(canonical_index: usize, this: &Reduction, log: &ReductionLog) -> bool {
log.reductions.iter().any(|other| {
if other.id == this.id {
return false;
}
match other.kind {
ReductionKind::TurnsCleared { first, last, .. } => {
canonical_index >= first && canonical_index <= last
}
_ => other.ptr.addr.index == canonical_index,
}
})
}
fn snippet_around(text: &str, start: usize, end: usize) -> String {
let lo = char_boundary_floor(text, start.saturating_sub(SNIPPET_RADIUS));
let hi_target = (end + SNIPPET_RADIUS).min(text.len());
let mut hi = hi_target;
while hi < text.len() && !text.is_char_boundary(hi) {
hi += 1;
}
let window = &text[lo..hi.min(text.len())];
let cap = char_boundary_floor(window, SNIPPET_MAX_BYTES);
window[..cap].to_string()
}
pub const REASONING_METADATA_KEYS: &[&str] = &[
"thinking",
"thinking_signature",
"redacted_thinking",
"reasoning",
"reasoning_content",
"reasoning_encrypted",
"thinking_blocks",
"pi_thought_signature",
];
pub const REASONING_CONTENT_PART_TYPES: &[&str] = &["thinking", "reasoning", "redacted_thinking"];
pub fn filter_reasoning_artifacts(history: &mut [ChatMessage]) -> usize {
let mut touched = 0usize;
for msg in history.iter_mut() {
let mut this_touched = false;
for key in REASONING_METADATA_KEYS {
if msg.metadata.remove(*key).is_some() {
this_touched = true;
}
}
if let Some(parts) = msg.content_parts.as_mut() {
let before = parts.len();
parts.retain(|p| {
p.get("type")
.and_then(|t| t.as_str())
.map(|t| !REASONING_CONTENT_PART_TYPES.contains(&t))
.unwrap_or(true)
});
if parts.len() != before {
this_touched = true;
}
}
if this_touched {
touched += 1;
}
}
touched
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{content_hash, make_id, stub, MessageAddr, SidecarPtr};
use supercode_interchange::Role;
fn tool_output_reduction(
id_ordinal: usize,
addr_index: usize,
original: &str,
kept: usize,
) -> Reduction {
let hash = content_hash(original.as_bytes());
let id = make_id(id_ordinal, &hash);
let summary = format!(
"t output truncated {}B, kept {kept}B — full output in session sidecar",
original.len()
);
Reduction {
id: id.clone(),
kind: ReductionKind::ToolOutputTruncated {
original_bytes: original.len(),
kept_bytes: kept,
},
ptr: SidecarPtr {
addr: MessageAddr {
index: addr_index,
role: Role::Tool,
},
span: Some((kept, original.len())),
content_hash: hash,
},
placeholder: stub::format(stub::Kind::ToolOutput, &id, &summary),
}
}
fn one_reduction_log(original: &str, kept: usize) -> (Vec<ChatMessage>, ReductionLog) {
let msg = ChatMessage::tool_result("c1", "bash", original.to_string());
let r = tool_output_reduction(0, 0, original, kept);
(
vec![msg],
ReductionLog {
reductions: vec![r],
expanded: vec![],
read_log: vec![],
attribution: None,
},
)
}
#[test]
fn expand_returns_exact_bytes_and_ranges() {
let original = "0123456789abcdefghij";
let (minted, log) = one_reduction_log(original, 10);
let id = log.reductions[0].id.clone();
let whole = expand_reduction(&log, &minted, None, &id, None).unwrap();
assert_eq!(whole.content, original);
assert_eq!(whole.total_bytes, original.len());
assert_eq!(whole.range, None);
let ranged = expand_reduction(&log, &minted, None, &id, Some((10, 15))).unwrap();
assert_eq!(ranged.content, "abcde");
assert_eq!(ranged.range, Some((10, 15)));
assert_eq!(ranged.total_bytes, original.len());
let clamped = expand_reduction(&log, &minted, None, &id, Some((15, 10_000))).unwrap();
assert_eq!(clamped.content, &original[15..]);
assert_eq!(clamped.range, Some((15, original.len())));
}
#[test]
fn expand_rejects_reversed_and_out_of_bounds_ranges() {
let original = "0123456789";
let (minted, log) = one_reduction_log(original, 4);
let id = log.reductions[0].id.clone();
let reversed = expand_reduction(&log, &minted, None, &id, Some((100, 5))).unwrap_err();
let msg = reversed.to_string();
assert!(msg.contains("reversed"), "{msg}");
assert!(msg.contains("[start, end)"), "{msg}");
assert!(msg.contains("10 bytes"), "{msg}");
let oob = expand_reduction(&log, &minted, None, &id, Some((11, 20))).unwrap_err();
let msg = oob.to_string();
assert!(msg.contains("beyond"), "{msg}");
assert!(msg.contains("10"), "{msg}");
}
#[test]
fn expand_unknown_id_errors_with_valid_id_hint() {
let log = ReductionLog {
reductions: vec![tool_output_reduction(0, 0, "abc", 1)],
expanded: vec![],
read_log: vec![],
attribution: None,
};
let err = expand_reduction(&log, &[], None, "r9999-dead", None).unwrap_err();
assert!(err.to_string().contains("r9999-dead"));
assert!(err.to_string().contains(&log.reductions[0].id));
}
#[test]
fn recorded_copy_supersedes_a_capped_minted_copy() {
let full = "F".repeat(1000);
let capped = format!(
"{}{}{} bytes total, showing first 600; full output in session sidecar]",
&full[..600],
CAP_NOTICE_MARKER,
1000
);
let (minted, log) = one_reduction_log(&capped, 100);
let id = log.reductions[0].id.clone();
let recorded = vec![ChatMessage::tool_result("c1", "bash", full.clone())];
let out = expand_reduction(&log, &minted, Some(&recorded), &id, None).unwrap();
assert_eq!(out.content, full);
assert_eq!(out.total_bytes, 1000);
let out = expand_reduction(&log, &minted, None, &id, None).unwrap();
assert_eq!(out.content, capped);
let drifted = vec![ChatMessage::tool_result("cX", "bash", "unrelated")];
let out = expand_reduction(&log, &minted, Some(&drifted), &id, None).unwrap();
assert_eq!(out.content, capped);
}
#[test]
fn recorded_copy_with_different_tool_call_id_never_supersedes() {
let old_full = format!("{}OLD-TIMELINE-TAIL", "S".repeat(600));
let capped = format!(
"{}{}{} bytes total, showing first 600; full output in session sidecar]",
&old_full[..600], CAP_NOTICE_MARKER,
900
);
let minted = vec![ChatMessage::tool_result("c-new", "bash", capped.clone())];
let r = tool_output_reduction(0, 0, &capped, 100);
let id = r.id.clone();
let log = ReductionLog {
reductions: vec![r],
expanded: vec![],
read_log: vec![],
attribution: None,
};
let recorded = vec![ChatMessage::tool_result("c-old", "bash", old_full.clone())];
let out = expand_reduction(&log, &minted, Some(&recorded), &id, None).unwrap();
assert_eq!(
out.content, capped,
"an old-timeline copy with a different tool_call_id must never supersede"
);
assert!(!out.content.contains("OLD-TIMELINE-TAIL"));
let same_id = vec![ChatMessage::tool_result("c-new", "bash", old_full.clone())];
let out = expand_reduction(&log, &minted, Some(&same_id), &id, None).unwrap();
assert_eq!(out.content, old_full);
}
#[test]
fn search_finds_hidden_but_not_kept_prefix() {
let original = "KEPTPREFIX-needle-is-here-in-the-hidden-tail";
let kept = "KEPTPREFIX".len();
let (minted, log) = one_reduction_log(original, kept);
let id = log.reductions[0].id.clone();
let hits = sidecar_search(&log, &minted, None, "needle").unwrap();
assert_eq!(hits.matches.len(), 1);
assert_eq!(hits.total_matches, 1);
assert!(!hits.truncated);
assert_eq!(hits.unresolvable, 0);
assert_eq!(hits.matches[0].reduction_id, id);
assert_eq!(hits.matches[0].kind, "tool-output");
assert!(hits.matches[0].snippet.contains("needle"));
let none = sidecar_search(&log, &minted, None, "KEPTPREFIX").unwrap();
assert!(
none.matches.is_empty() && none.total_matches == 0,
"kept prefix must not be searchable: {none:?}"
);
}
#[test]
fn search_regex_and_literal_fallback() {
let original = "error: file not found at /a/b/c.rs [line 42";
let (minted, log) = one_reduction_log(original, 0);
let hits = sidecar_search(&log, &minted, None, r"line \d+").unwrap();
assert_eq!(hits.matches.len(), 1);
let hits2 = sidecar_search(&log, &minted, None, "c.rs [line").unwrap();
assert_eq!(hits2.matches.len(), 1);
assert!(hits2.matches[0].snippet.contains("c.rs [line"));
}
#[test]
fn search_rejects_empty_query_and_caps_result_size() {
let original = "z".repeat(50_000);
let (minted, log) = one_reduction_log(&original, 0);
assert!(sidecar_search(&log, &minted, None, "").is_err());
assert!(sidecar_search(&log, &minted, None, " ").is_err());
let hits = sidecar_search(&log, &minted, None, "z").unwrap();
assert_eq!(hits.matches.len(), MAX_MATCHES);
assert_eq!(hits.total_matches, 50_000);
assert!(hits.truncated);
let serialized = serde_json::to_string(&hits).unwrap();
assert!(
serialized.len() <= MAX_RESULT_BYTES,
"serialized result must stay under the byte cap: {} bytes",
serialized.len()
);
let reparsed: SidecarSearchResult = serde_json::from_str(&serialized).unwrap();
assert_eq!(reparsed, hits);
let hits = sidecar_search(&log, &minted, None, "z.*").unwrap();
assert!(hits.matches[0].snippet.len() <= SNIPPET_MAX_BYTES);
assert!(serialized_len(&hits) <= MAX_RESULT_BYTES);
}
#[test]
fn search_skips_unresolvable_reductions_instead_of_aborting() {
let original = "the needle is in here";
let (minted, log) = one_reduction_log(original, 0);
let mut log = log;
log.reductions.push(tool_output_reduction(1, 99, "gone", 0));
let hits = sidecar_search(&log, &minted, None, "needle").unwrap();
assert_eq!(hits.matches.len(), 1, "{hits:?}");
assert_eq!(hits.unresolvable, 1);
}
#[test]
fn turns_cleared_render_carries_tool_call_payloads() {
use supercode_interchange::{FunctionCall, ToolCall};
let call = ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![ToolCall {
id: "w1".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "write_file".to_string(),
arguments: serde_json::json!({
"path": "notes.txt",
"content": "ARGS-ONLY-PAYLOAD-77"
})
.to_string(),
},
}]),
tool_call_id: None,
name: None,
metadata: Default::default(),
};
let result = ChatMessage::tool_result("w1", "write_file", "ok");
let rendered = render_turns(&[call, result]);
assert!(rendered.contains("ARGS-ONLY-PAYLOAD-77"), "{rendered}");
assert!(rendered.contains("write_file"), "{rendered}");
assert!(rendered.contains("w1"), "{rendered}");
assert!(rendered.contains("tool_call_id=w1"), "{rendered}");
}
#[test]
fn filter_reasoning_artifacts_strips_every_documented_metadata_key() {
let mut msg = ChatMessage::assistant("the answer");
for key in REASONING_METADATA_KEYS {
msg.metadata
.insert(key.to_string(), "secret-cot".to_string());
}
msg.metadata
.insert("unrelated".to_string(), "kept".to_string());
let mut history = vec![msg];
let touched = filter_reasoning_artifacts(&mut history);
assert_eq!(touched, 1);
for key in REASONING_METADATA_KEYS {
assert!(
!history[0].metadata.contains_key(*key),
"{key} should have been stripped"
);
}
assert_eq!(
history[0].metadata.get("unrelated").map(String::as_str),
Some("kept"),
"non-reasoning metadata must survive untouched"
);
}
#[test]
fn filter_reasoning_artifacts_strips_reasoning_content_parts_keeps_others() {
let mut msg = ChatMessage::assistant("");
msg.content_parts = Some(vec![
serde_json::json!({"type": "text", "text": "visible"}),
serde_json::json!({"type": "thinking", "text": "model-A's private CoT"}),
serde_json::json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}),
]);
let mut history = vec![msg];
let touched = filter_reasoning_artifacts(&mut history);
assert_eq!(touched, 1);
let parts = history[0].content_parts.as_ref().unwrap();
assert_eq!(parts.len(), 2, "{parts:?}");
assert!(parts.iter().all(|p| p["type"] != "thinking"), "{parts:?}");
assert!(parts.iter().any(|p| p["type"] == "text"), "{parts:?}");
assert!(parts.iter().any(|p| p["type"] == "image_url"), "{parts:?}");
}
#[test]
fn filter_reasoning_artifacts_leaves_clean_messages_untouched() {
let mut history = vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi there"),
ChatMessage::tool_result("call_1", "read_file", "file contents"),
];
let before = history.clone();
let touched = filter_reasoning_artifacts(&mut history);
assert_eq!(touched, 0);
for (a, b) in history.iter().zip(before.iter()) {
assert_eq!(a.content, b.content);
assert_eq!(a.metadata, b.metadata);
}
}
#[test]
fn filter_reasoning_artifacts_only_counts_actually_touched_messages() {
let mut clean = ChatMessage::assistant("clean turn");
let mut dirty = ChatMessage::assistant("dirty turn");
dirty
.metadata
.insert("thinking".to_string(), "secret".to_string());
let mut history = vec![clean.clone(), dirty];
let touched = filter_reasoning_artifacts(&mut history);
assert_eq!(touched, 1);
clean.metadata.clear();
assert_eq!(history[0].content, clean.content);
assert!(!history[1].metadata.contains_key("thinking"));
}
#[test]
fn filter_reasoning_artifacts_strips_thinking_blocks_and_pi_thought_signature() {
assert!(REASONING_METADATA_KEYS.contains(&"thinking_blocks"));
assert!(REASONING_METADATA_KEYS.contains(&"pi_thought_signature"));
let mut msg = ChatMessage::assistant("here's my answer");
msg.metadata.insert(
"thinking_blocks".to_string(),
serde_json::json!([{"type": "thinking", "thinking": "model-A's private CoT", "signature": "sig-abc"}]).to_string(),
);
msg.metadata.insert(
"pi_thought_signature".to_string(),
"google-opaque-reasoning-continuity-token".to_string(),
);
let mut history = vec![msg];
let touched = filter_reasoning_artifacts(&mut history);
assert_eq!(touched, 1);
assert!(!history[0].metadata.contains_key("thinking_blocks"));
assert!(!history[0].metadata.contains_key("pi_thought_signature"));
}
#[test]
fn filter_reasoning_artifacts_does_not_over_strip_non_reasoning_pi_keys() {
assert!(!REASONING_METADATA_KEYS.contains(&"pi_text_signature"));
assert!(!REASONING_METADATA_KEYS.contains(&"pi_thinking_redacted"));
let mut msg = ChatMessage::assistant("here's my answer");
msg.metadata
.insert("pi_text_signature".to_string(), "replay-id-123".to_string());
msg.metadata
.insert("pi_thinking_redacted".to_string(), "true".to_string());
let mut history = vec![msg];
filter_reasoning_artifacts(&mut history);
assert_eq!(
history[0]
.metadata
.get("pi_text_signature")
.map(String::as_str),
Some("replay-id-123")
);
assert_eq!(
history[0]
.metadata
.get("pi_thinking_redacted")
.map(String::as_str),
Some("true")
);
}
}