Skip to main content

agent_engine/runtime/trace/
diagnostics.rs

1//! Cache-prefix diagnostics (Task 12, spec §6.6) and the `/context` report.
2//!
3//! ## Canonicalization (documented approximations)
4//!
5//! The tools-prefix / system-prefix / history-tail digests are keyed HMAC
6//! over **canonical component bytes**, derived from the same structural
7//! inputs the request builder used — the registered tool schemas, the
8//! caller-supplied system prompt, and the annotated message array:
9//!
10//! - **tools prefix**: for each tool, in send order:
11//!   `name ++ 0x1f ++ canonical-JSON(input_schema) ++ 0x1e`. This matches
12//!   stable-prefix semantics (a tool added, removed, reordered, or with a
13//!   changed schema changes the digest) but is *not* the exact wire slice —
14//!   provider-specific envelope keys and separators are excluded by design,
15//!   so the digest is stable across cosmetic wire changes.
16//! - **system prefix**: the exact UTF-8 bytes of the system prompt string.
17//! - **history tail**: for each message strictly *after* the last
18//!   message-level cache boundary (`cache_control` annotation this process
19//!   applied), `canonical-JSON(message) ++ 0x1e`. When no boundary exists
20//!   the tail is the whole history. Canonical JSON is `serde_json::to_vec`
21//!   of the already-built message value — the same value the wire body was
22//!   serialized from, but not byte-sliced out of the wire buffer.
23//!
24//! None of these bytes are retained: only lengths and keyed digests
25//! (installation-scoped HMAC key, see `trace::key`) leave this module.
26//!
27//! ## Session snapshot
28//!
29//! [`CacheSnapshotStore`] keeps the previous emitted request's component
30//! digests (bounded metadata only — digests, byte lengths, tool IDs) inside
31//! the session's `TraceContext`. Compare-and-update is atomic under one
32//! mutex, per emitted request: a retried identical request compares equal
33//! (all segments `Unchanged`); an intentional tool order/schema change is
34//! flagged with the precise tool IDs. No provider or prompt content is
35//! ever stored.
36
37use super::key::{keyed_digest, DigestDomain, TraceDigestKey};
38use super::types::{CacheSegmentDelta, PrefixMeta, SegmentChange, TraceId};
39use serde_json::Value;
40use std::sync::Mutex;
41
42/// Record separator between canonical components.
43const RS: u8 = 0x1e;
44/// Unit separator between a tool name and its schema bytes.
45const US: u8 = 0x1f;
46
47// --- Canonical component bytes ---
48
49/// Canonical tools-prefix bytes (see module docs for the exact recipe).
50pub fn tools_prefix_bytes(tools_schema: &[Value]) -> Vec<u8> {
51    let mut out = Vec::new();
52    for tool in tools_schema {
53        if let Some(name) = tool["name"].as_str() {
54            out.extend_from_slice(name.as_bytes());
55        }
56        out.push(US);
57        if let Ok(schema) = serde_json::to_vec(&tool["input_schema"]) {
58            out.extend_from_slice(&schema);
59        }
60        out.push(RS);
61    }
62    out
63}
64
65/// Canonical system-prefix bytes: the system prompt itself.
66pub fn system_prefix_bytes(system_prompt: &str) -> Vec<u8> {
67    system_prompt.as_bytes().to_vec()
68}
69
70/// Index of the first history-tail message: one past the last message that
71/// carries a message-level cache boundary annotation. With no boundary the
72/// whole history is the tail.
73fn history_tail_start(messages: &[crate::SharedMessage]) -> usize {
74    messages
75        .iter()
76        .rposition(|m| {
77            m["content"]
78                .as_array()
79                .and_then(|blocks| blocks.last())
80                .and_then(|b| b.get("cache_control"))
81                .is_some()
82        })
83        .map(|idx| idx + 1)
84        .unwrap_or(0)
85}
86
87/// Canonical history-tail bytes (see module docs).
88pub fn history_tail_bytes(messages: &[crate::SharedMessage]) -> Vec<u8> {
89    let start = history_tail_start(messages);
90    let mut out = Vec::new();
91    for message in &messages[start..] {
92        if let Ok(bytes) = serde_json::to_vec(&**message) {
93            out.extend_from_slice(&bytes);
94        }
95        out.push(RS);
96    }
97    out
98}
99
100// --- Bounded per-segment snapshot ---
101
102/// Bounded metadata snapshot of one segment: digest + canonical byte length.
103#[derive(Debug, Clone, PartialEq, Eq)]
104struct SegmentSnapshot {
105    digest: super::key::ComponentDigest,
106    byte_len: u64,
107}
108
109impl SegmentSnapshot {
110    fn from_meta(meta: &PrefixMeta) -> Self {
111        Self {
112            digest: meta.digest.clone(),
113            byte_len: meta.byte_len,
114        }
115    }
116}
117
118/// Bounded metadata identity of one tool for order/schema comparison.
119#[derive(Debug, Clone, PartialEq, Eq)]
120struct ToolSnapshot {
121    stable_id: TraceId,
122    schema_digest: super::key::ComponentDigest,
123}
124
125#[derive(Debug, Default)]
126struct SnapshotInner {
127    tools: Option<SegmentSnapshot>,
128    system: Option<SegmentSnapshot>,
129    history_tail: Option<SegmentSnapshot>,
130    tool_list: Vec<ToolSnapshot>,
131    /// Last computed delta + prefix metas, retained (bounded) for `/context`.
132    last: Option<CacheActivity>,
133}
134
135/// Latest cache diagnostics, retained for the `/context` report. Bounded
136/// metadata only (digests, byte lengths, enums, tool IDs).
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct CacheActivity {
139    pub tools_prefix: Option<PrefixMeta>,
140    pub system_prefix: Option<PrefixMeta>,
141    pub history_tail: Option<PrefixMeta>,
142    pub delta: CacheSegmentDelta,
143}
144
145/// Computed diagnostics for one outgoing request.
146#[derive(Debug, Clone, Default)]
147pub struct CacheDiagnostics {
148    pub tools_prefix: Option<PrefixMeta>,
149    pub system_prefix: Option<PrefixMeta>,
150    pub history_tail: Option<PrefixMeta>,
151    pub delta: Option<CacheSegmentDelta>,
152}
153
154/// Session-scoped previous-component snapshot (see module docs). Shared via
155/// `Arc` inside `TraceContext`; all mutation is under one internal mutex so
156/// compare-and-update is atomic per emitted request.
157#[derive(Debug, Default)]
158pub struct CacheSnapshotStore {
159    inner: Mutex<SnapshotInner>,
160}
161
162impl CacheSnapshotStore {
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Compare the current request's components against the previous
168    /// snapshot, update the snapshot, and return the diagnostics. Requires
169    /// the digest key — without it no digests exist to compare, so the
170    /// caller gets `CacheDiagnostics::default()` and the snapshot is left
171    /// untouched (never cleared: a transient key failure must not turn the
172    /// next successful comparison into a spurious `New`).
173    pub fn compare_and_update(
174        &self,
175        key: Option<&TraceDigestKey>,
176        tools_schema: &[Value],
177        system_prompt: Option<&str>,
178        messages: &[crate::SharedMessage],
179    ) -> CacheDiagnostics {
180        let Some(key) = key else {
181            return CacheDiagnostics::default();
182        };
183
184        let tools_bytes = tools_prefix_bytes(tools_schema);
185        let tools_prefix = (!tools_schema.is_empty()).then(|| PrefixMeta {
186            byte_len: tools_bytes.len() as u64,
187            digest: keyed_digest(key, DigestDomain::ToolsPrefix, &tools_bytes),
188        });
189        let system_prefix = system_prompt.filter(|s| !s.is_empty()).map(|s| {
190            let bytes = system_prefix_bytes(s);
191            PrefixMeta {
192                byte_len: bytes.len() as u64,
193                digest: keyed_digest(key, DigestDomain::SystemPrefix, &bytes),
194            }
195        });
196        let tail_bytes = history_tail_bytes(messages);
197        let history_tail = (!messages.is_empty()).then(|| PrefixMeta {
198            byte_len: tail_bytes.len() as u64,
199            digest: keyed_digest(key, DigestDomain::HistoryTail, &tail_bytes),
200        });
201        let tool_list: Vec<ToolSnapshot> = tools_schema
202            .iter()
203            .filter_map(|tool| {
204                let name = tool["name"].as_str()?;
205                let stable_id = TraceId::new(name).ok()?;
206                let schema = serde_json::to_vec(&tool["input_schema"]).ok()?;
207                Some(ToolSnapshot {
208                    stable_id,
209                    schema_digest: keyed_digest(key, DigestDomain::ToolSchema, &schema),
210                })
211            })
212            .collect();
213
214        let mut inner = self.inner.lock().expect("cache snapshot store poisoned");
215        let had_previous = inner.tools.is_some()
216            || inner.system.is_some()
217            || inner.history_tail.is_some()
218            || !inner.tool_list.is_empty();
219
220        let delta = if had_previous {
221            let tools_change = segment_change(inner.tools.as_ref(), tools_prefix.as_ref());
222            let system_change = segment_change(inner.system.as_ref(), system_prefix.as_ref());
223            let tail_change = segment_change(inner.history_tail.as_ref(), history_tail.as_ref());
224            let (changed_tool_ids, tool_order_changed) =
225                tool_delta(&inner.tool_list, &tool_list, tools_change);
226            let (reused, recomputed) = reuse_estimate(&[
227                (tools_change, tools_prefix.as_ref()),
228                (system_change, system_prefix.as_ref()),
229                (tail_change, history_tail.as_ref()),
230            ]);
231            CacheSegmentDelta {
232                tools: tools_change,
233                system: system_change,
234                history_tail: tail_change,
235                changed_tool_ids,
236                tool_order_changed,
237                estimated_reused_bytes: Some(reused),
238                estimated_recomputed_bytes: Some(recomputed),
239            }
240        } else {
241            CacheSegmentDelta {
242                tools: tools_prefix.as_ref().map(|_| SegmentChange::New),
243                system: system_prefix.as_ref().map(|_| SegmentChange::New),
244                history_tail: history_tail.as_ref().map(|_| SegmentChange::New),
245                changed_tool_ids: Vec::new(),
246                tool_order_changed: false,
247                estimated_reused_bytes: Some(0),
248                estimated_recomputed_bytes: Some(
249                    [&tools_prefix, &system_prefix, &history_tail]
250                        .iter()
251                        .filter_map(|m| m.as_ref().map(|m| m.byte_len))
252                        .sum(),
253                ),
254            }
255        };
256
257        inner.tools = tools_prefix.as_ref().map(SegmentSnapshot::from_meta);
258        inner.system = system_prefix.as_ref().map(SegmentSnapshot::from_meta);
259        inner.history_tail = history_tail.as_ref().map(SegmentSnapshot::from_meta);
260        inner.tool_list = tool_list;
261        inner.last = Some(CacheActivity {
262            tools_prefix: tools_prefix.clone(),
263            system_prefix: system_prefix.clone(),
264            history_tail: history_tail.clone(),
265            delta: delta.clone(),
266        });
267
268        CacheDiagnostics {
269            tools_prefix,
270            system_prefix,
271            history_tail,
272            delta: Some(delta),
273        }
274    }
275
276    /// Latest computed cache activity (for `/context`).
277    pub fn last_activity(&self) -> Option<CacheActivity> {
278        self.inner
279            .lock()
280            .expect("cache snapshot store poisoned")
281            .last
282            .clone()
283    }
284}
285
286fn segment_change(
287    previous: Option<&SegmentSnapshot>,
288    current: Option<&PrefixMeta>,
289) -> Option<SegmentChange> {
290    match (previous, current) {
291        (None, None) => None,
292        (None, Some(_)) | (Some(_), None) => Some(SegmentChange::New),
293        (Some(prev), Some(cur)) if prev.digest == cur.digest => Some(SegmentChange::Unchanged),
294        _ => Some(SegmentChange::Changed),
295    }
296}
297
298/// Changed tool IDs (schema changed, added, or removed) and whether the
299/// same tool set was merely reordered.
300fn tool_delta(
301    previous: &[ToolSnapshot],
302    current: &[ToolSnapshot],
303    tools_change: Option<SegmentChange>,
304) -> (Vec<TraceId>, bool) {
305    if tools_change != Some(SegmentChange::Changed) {
306        return (Vec::new(), false);
307    }
308    let mut changed: Vec<TraceId> = Vec::new();
309    for cur in current {
310        match previous.iter().find(|p| p.stable_id == cur.stable_id) {
311            None => changed.push(cur.stable_id.clone()),
312            Some(prev) if prev.schema_digest != cur.schema_digest => {
313                changed.push(cur.stable_id.clone())
314            }
315            Some(_) => {}
316        }
317    }
318    for prev in previous {
319        if !current.iter().any(|c| c.stable_id == prev.stable_id) {
320            changed.push(prev.stable_id.clone());
321        }
322    }
323    // Same multiset of (id, schema) pairs in a different order?
324    let order_changed = changed.is_empty() && previous != current;
325    (changed, order_changed)
326}
327
328fn reuse_estimate(segments: &[(Option<SegmentChange>, Option<&PrefixMeta>)]) -> (u64, u64) {
329    let mut reused = 0u64;
330    let mut recomputed = 0u64;
331    for (change, meta) in segments {
332        let Some(meta) = meta else { continue };
333        match change {
334            Some(SegmentChange::Unchanged) => reused += meta.byte_len,
335            Some(SegmentChange::Changed) | Some(SegmentChange::New) => recomputed += meta.byte_len,
336            None => {}
337        }
338    }
339    (reused, recomputed)
340}
341
342// --- `/context` report ---
343
344/// Why a `/context` line has no number: honest provenance, never a
345/// fabricated zero.
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum ReportValue {
348    Count(u64),
349    /// The owning surface did not provide this data to the report.
350    Unavailable,
351}
352
353impl std::fmt::Display for ReportValue {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        match self {
356            ReportValue::Count(n) => write!(f, "{n}"),
357            ReportValue::Unavailable => f.write_str("unavailable"),
358        }
359    }
360}
361
362/// Structured `/context` report: counts, bytes, enums, and counters only —
363/// never prompt/tool/skill/memory content.
364#[derive(Debug, Clone)]
365pub struct ContextReport {
366    pub model: String,
367    pub system_prompt_bytes: ReportValue,
368    pub tool_count: ReportValue,
369    /// History message count, when the calling surface owns and provided
370    /// the conversation; `Unavailable` otherwise (the runtime does not own
371    /// session history — it is never fabricated here).
372    pub history_messages: ReportValue,
373    pub history_bytes: ReportValue,
374    /// Loaded skills / memory documents, when enumerable from runtime
375    /// state. `Unavailable` (with that provenance printed) when the
376    /// runtime cannot enumerate them without owning surface state.
377    pub loaded_skills: ReportValue,
378    pub loaded_memories: ReportValue,
379    pub cache: Option<CacheActivity>,
380    pub trace_enabled: bool,
381    pub writer_stats: Option<crate::runtime::telemetry::WriterStats>,
382    pub degraded_records: u64,
383}
384
385fn fmt_change(change: Option<SegmentChange>) -> &'static str {
386    match change {
387        Some(SegmentChange::Unchanged) => "unchanged",
388        Some(SegmentChange::Changed) => "changed",
389        Some(SegmentChange::New) => "new",
390        None => "absent",
391    }
392}
393
394impl ContextReport {
395    /// Render for terminal display. Metadata only — no content.
396    pub fn render(&self) -> String {
397        let mut out = String::new();
398        use std::fmt::Write as _;
399        let _ = writeln!(out, "context — model {}", self.model);
400        let _ = writeln!(out, "  system prompt: {} bytes", self.system_prompt_bytes);
401        let _ = writeln!(out, "  tools: {}", self.tool_count);
402        let _ = writeln!(
403            out,
404            "  history: {} messages, {} bytes",
405            self.history_messages, self.history_bytes
406        );
407        let _ = writeln!(
408            out,
409            "  loaded skills: {}, memories: {}",
410            self.loaded_skills, self.loaded_memories
411        );
412        match &self.cache {
413            Some(activity) => {
414                let d = &activity.delta;
415                let _ = writeln!(
416                    out,
417                    "  cache: tools {}, system {}, history tail {}",
418                    fmt_change(d.tools),
419                    fmt_change(d.system),
420                    fmt_change(d.history_tail),
421                );
422                if !d.changed_tool_ids.is_empty() {
423                    let ids: Vec<&str> = d.changed_tool_ids.iter().map(|t| t.as_str()).collect();
424                    let _ = writeln!(out, "  changed tools: {}", ids.join(", "));
425                }
426                if d.tool_order_changed {
427                    let _ = writeln!(out, "  tool order changed (prefix invalidated)");
428                }
429                if let (Some(reused), Some(recomputed)) =
430                    (d.estimated_reused_bytes, d.estimated_recomputed_bytes)
431                {
432                    let _ = writeln!(
433                        out,
434                        "  estimated reuse: {reused} bytes reused, {recomputed} bytes recomputed",
435                    );
436                }
437            }
438            None => {
439                let _ = writeln!(
440                    out,
441                    "  cache: no diagnostics yet (tracing {} this session)",
442                    if self.trace_enabled {
443                        "enabled, no traced request"
444                    } else {
445                        "disabled"
446                    }
447                );
448            }
449        }
450        match self.writer_stats {
451            Some(stats) => {
452                let _ = writeln!(
453                    out,
454                    "  trace writer: {} enqueued, {} written, {} dropped; {} degraded records",
455                    stats.enqueued, stats.written, stats.dropped, self.degraded_records,
456                );
457            }
458            None => {
459                let _ = writeln!(
460                    out,
461                    "  trace writer: off; {} degraded records",
462                    self.degraded_records
463                );
464            }
465        }
466        out.trim_end().to_string()
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use std::sync::Arc;
474
475    fn test_key() -> TraceDigestKey {
476        let dir = tempfile::tempdir().expect("tempdir");
477        super::super::key::load_or_create_digest_key_at(&dir.path().join("k.key"))
478            .expect("test key")
479    }
480
481    fn tool(name: &str, schema: Value) -> Value {
482        serde_json::json!({"name": name, "input_schema": schema})
483    }
484
485    fn msg(role: &str, text: &str) -> crate::SharedMessage {
486        Arc::new(serde_json::json!({"role": role, "content": text}))
487    }
488
489    #[test]
490    fn first_request_is_new_then_identical_retry_is_unchanged() {
491        let key = test_key();
492        let store = CacheSnapshotStore::new();
493        let tools = vec![tool("alpha", serde_json::json!({"type": "object"}))];
494        let messages = vec![msg("user", "hi")];
495
496        let first = store.compare_and_update(Some(&key), &tools, Some("sys"), &messages);
497        let d = first.delta.expect("delta");
498        assert_eq!(d.tools, Some(SegmentChange::New));
499        assert_eq!(d.system, Some(SegmentChange::New));
500        assert_eq!(d.history_tail, Some(SegmentChange::New));
501        assert_eq!(d.estimated_reused_bytes, Some(0));
502
503        // Retried identical attempt: everything unchanged.
504        let second = store.compare_and_update(Some(&key), &tools, Some("sys"), &messages);
505        let d = second.delta.expect("delta");
506        assert_eq!(d.tools, Some(SegmentChange::Unchanged));
507        assert_eq!(d.system, Some(SegmentChange::Unchanged));
508        assert_eq!(d.history_tail, Some(SegmentChange::Unchanged));
509        assert!(d.changed_tool_ids.is_empty());
510        assert!(!d.tool_order_changed);
511        assert_eq!(d.estimated_recomputed_bytes, Some(0));
512        assert!(d.estimated_reused_bytes.unwrap() > 0);
513    }
514
515    #[test]
516    fn tool_order_change_flags_prefix_change_without_changed_ids() {
517        let key = test_key();
518        let store = CacheSnapshotStore::new();
519        let a = tool("alpha", serde_json::json!({"type": "object"}));
520        let b = tool("beta", serde_json::json!({"type": "object"}));
521        let messages = vec![msg("user", "hi")];
522
523        store.compare_and_update(Some(&key), &[a.clone(), b.clone()], None, &messages);
524        let swapped = store.compare_and_update(Some(&key), &[b, a], None, &messages);
525        let d = swapped.delta.expect("delta");
526        assert_eq!(d.tools, Some(SegmentChange::Changed));
527        assert!(d.changed_tool_ids.is_empty(), "same tools, only reordered");
528        assert!(d.tool_order_changed);
529    }
530
531    #[test]
532    fn tool_schema_change_names_the_precise_tool() {
533        let key = test_key();
534        let store = CacheSnapshotStore::new();
535        let a1 = tool("alpha", serde_json::json!({"type": "object"}));
536        let a2 = tool(
537            "alpha",
538            serde_json::json!({"type": "object", "properties": {"x": {}}}),
539        );
540        let b = tool("beta", serde_json::json!({"type": "object"}));
541        let messages = vec![msg("user", "hi")];
542
543        store.compare_and_update(Some(&key), &[a1, b.clone()], None, &messages);
544        let changed = store.compare_and_update(Some(&key), &[a2, b], None, &messages);
545        let d = changed.delta.expect("delta");
546        assert_eq!(d.tools, Some(SegmentChange::Changed));
547        assert_eq!(
548            d.changed_tool_ids
549                .iter()
550                .map(|t| t.as_str())
551                .collect::<Vec<_>>(),
552            vec!["alpha"]
553        );
554        assert!(!d.tool_order_changed);
555    }
556
557    #[test]
558    fn prefix_digests_are_keyed_hmac_over_canonical_bytes() {
559        let key = test_key();
560        let store = CacheSnapshotStore::new();
561        let tools = vec![tool("alpha", serde_json::json!({"type": "object"}))];
562        let messages = vec![msg("user", "hello")];
563        let diag = store.compare_and_update(Some(&key), &tools, Some("sys"), &messages);
564
565        let expected_tools =
566            keyed_digest(&key, DigestDomain::ToolsPrefix, &tools_prefix_bytes(&tools));
567        assert_eq!(diag.tools_prefix.unwrap().digest, expected_tools);
568
569        let expected_system = keyed_digest(&key, DigestDomain::SystemPrefix, b"sys");
570        assert_eq!(diag.system_prefix.unwrap().digest, expected_system);
571
572        let expected_tail = keyed_digest(
573            &key,
574            DigestDomain::HistoryTail,
575            &history_tail_bytes(&messages),
576        );
577        assert_eq!(diag.history_tail.unwrap().digest, expected_tail);
578    }
579
580    #[test]
581    fn history_tail_excludes_messages_up_to_last_cache_boundary() {
582        let cached: crate::SharedMessage = Arc::new(serde_json::json!({
583            "role": "user",
584            "content": [
585                {"type": "text", "text": "old", "cache_control": {"type": "ephemeral"}}
586            ]
587        }));
588        let fresh = msg("user", "new");
589        let tail = history_tail_bytes(&[cached.clone(), fresh.clone()]);
590        let only_fresh = history_tail_bytes(&[fresh.clone()]);
591        // The tail after the boundary is exactly the fresh message.
592        assert_eq!(tail, only_fresh);
593        // Without a boundary, the whole history is the tail.
594        let full = history_tail_bytes(&[cached, fresh]);
595        assert_eq!(full, only_fresh);
596    }
597
598    #[test]
599    fn missing_key_yields_no_diagnostics_and_preserves_snapshot() {
600        let key = test_key();
601        let store = CacheSnapshotStore::new();
602        let tools = vec![tool("alpha", serde_json::json!({"type": "object"}))];
603        let messages = vec![msg("user", "hi")];
604        store.compare_and_update(Some(&key), &tools, None, &messages);
605
606        let degraded = store.compare_and_update(None, &tools, None, &messages);
607        assert!(degraded.delta.is_none());
608        assert!(degraded.tools_prefix.is_none());
609
610        // Snapshot survived the degraded request: next comparison is still
611        // against the first request, not spuriously New.
612        let after = store.compare_and_update(Some(&key), &tools, None, &messages);
613        assert_eq!(after.delta.unwrap().tools, Some(SegmentChange::Unchanged));
614    }
615
616    #[test]
617    fn cache_meta_without_new_fields_still_deserializes() {
618        // Backward compatibility: a record written before Task 12.
619        let old = serde_json::json!({"boundaries": []});
620        let meta: super::super::types::CacheMeta =
621            serde_json::from_value(old).expect("old CacheMeta parses");
622        assert!(meta.history_tail.is_none());
623        assert!(meta.delta.is_none());
624    }
625
626    #[test]
627    fn context_report_render_contains_no_content_sentinel() {
628        let secret = "SENTINEL-SECRET-9f8e7d";
629        // Build a report from inputs that contain the sentinel; only counts
630        // and digests may surface.
631        let key = test_key();
632        let store = CacheSnapshotStore::new();
633        let tools = vec![tool(
634            "alpha",
635            serde_json::json!({"type": "object", "d": secret}),
636        )];
637        let messages = vec![msg("user", secret)];
638        store.compare_and_update(Some(&key), &tools, Some(secret), &messages);
639        let report = ContextReport {
640            model: "anthropic/claude-test".to_string(),
641            system_prompt_bytes: ReportValue::Count(secret.len() as u64),
642            tool_count: ReportValue::Count(1),
643            history_messages: ReportValue::Count(1),
644            history_bytes: ReportValue::Count(42),
645            loaded_skills: ReportValue::Unavailable,
646            loaded_memories: ReportValue::Unavailable,
647            cache: store.last_activity(),
648            trace_enabled: true,
649            writer_stats: None,
650            degraded_records: 0,
651        };
652        let rendered = report.render();
653        assert!(!rendered.contains(secret), "sentinel leaked: {rendered}");
654        assert!(rendered.contains("unavailable"));
655        assert!(rendered.contains("cache:"));
656    }
657}