Skip to main content

lean_ctx/proxy/
ccr.rs

1//! Content-addressed recovery (CCR) for the proxy's lossy rewrites (#482).
2//!
3//! When the proxy prunes an old `tool_result` from conversation history, the
4//! lossy stub used to say *"re-read the file"* — which is stale-unsafe by
5//! construction: in an agent session files are edited or deleted between turns,
6//! so a re-read returns the *current* bytes (or fails), not the historical
7//! version the conversation actually showed. The model could then silently
8//! reason about the wrong content.
9//!
10//! CCR fixes this by persisting the **verbatim original** to the shared,
11//! content-addressed tee store (`{state}/tee/`, reused from the shell path) and
12//! embedding a **retrieval handle** — the absolute path of that file — in the
13//! stub. Retrieval is MCP-independent: the agent reads the path with its native
14//! file read; no lean-ctx tool has to be attached.
15//!
16//! ## Cache-safety (#448)
17//! The handle is the file path, and the path is a pure function of the content
18//! hash ([`crate::core::hasher::hash_short`]). For a fixed pruned message the
19//! handle is therefore byte-identical on every later turn, so the provider
20//! prompt-cache prefix is never invalidated. The on-disk *write* is best-effort
21//! and never affects the returned handle — only retrievability degrades if the
22//! write (or the 24h TTL cleanup) loses the file, so a stub can never become
23//! non-deterministic based on filesystem state.
24
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use serde_json::Value;
30
31/// Opening delimiter of an in-band retrieval marker: `<lc_expand:HASH>` (#493).
32const EXPAND_OPEN: &str = "<lc_expand:";
33/// Closing delimiter of an in-band retrieval marker.
34const EXPAND_CLOSE: char = '>';
35
36/// Originals smaller than this are not worth a tee file + handle; the caller
37/// keeps its plain stub. Matches the spirit of the prune length thresholds.
38pub(crate) const MIN_TEE_BYTES: usize = 512;
39
40/// Throttle the O(dir) TTL cleanup so the prune hot path does at most one
41/// directory scan per this interval (the write itself is content-addressed and
42/// idempotent, so steady-state cost is a single `stat`).
43const CLEANUP_INTERVAL_SECS: u64 = 600;
44
45/// Length of the content hash in a proxy/json tee name ([`hash_short`]).
46const TEE_HASH_LEN: usize = 16;
47/// Length of the command hash in a shell tee name (`shell::redact::save_tee`).
48const SHELL_TEE_HASH_LEN: usize = 8;
49
50/// Deterministic tee path for `content`:
51/// `{state}/tee/{prefix}_{blake3(content)[..16]}.log`. Pure (no I/O) so a stub
52/// embedding it stays byte-stable regardless of filesystem state. `prefix`
53/// segregates the producer (`proxy` for history-prune / live-compression stubs,
54/// `conv` for conversation history messages, `json` for the JSON crusher's
55/// lossy originals, #936) yet keeps one shared
56/// store + one resolver ([`resolve_tee`]).
57fn tee_path(content: &str, prefix: &str) -> Option<PathBuf> {
58    let dir = crate::core::paths::state_dir().ok()?.join("tee");
59    let hash = crate::core::hasher::hash_short(content);
60    Some(dir.join(format!("{prefix}_{hash}.log")))
61}
62
63/// Run the shared 24h TTL cleanup at most once per [`CLEANUP_INTERVAL_SECS`].
64fn maybe_cleanup(tee_dir: &Path) {
65    static LAST: AtomicU64 = AtomicU64::new(0);
66    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
67        return;
68    };
69    let now = now.as_secs();
70    let last = LAST.load(Ordering::Relaxed);
71    if now.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
72        return;
73    }
74    // Only one thread wins the slot; the rest skip until the next interval.
75    if LAST
76        .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
77        .is_ok()
78    {
79        crate::shell::cleanup_old_tee_logs(tee_dir);
80    }
81}
82
83/// Persist `content` verbatim (best-effort, secret-redacted) to the
84/// content-addressed tee store and return its retrieval handle (the absolute
85/// path). Returns `None` only when `content` is below [`MIN_TEE_BYTES`] or the
86/// state dir can't be resolved — never because the *write* failed, so the
87/// returned handle is a pure function of the content and the embedding stub
88/// stays deterministic. Re-persisting identical content is idempotent: same
89/// content → same path → the existing file is left untouched.
90pub(crate) fn persist(content: &str) -> Option<String> {
91    persist_with(content, "proxy")
92}
93
94/// Persist a dropped conversation message under a compact `conv_` handle.
95///
96/// Conversation messages may be short, so unlike tool-output tees this path has
97/// no minimum-size gate. The returned basename is sufficient for `ctx_expand` and
98/// avoids putting machine-specific absolute paths into provider prompts.
99#[cfg_attr(not(test), allow(dead_code))] // conversation tee handle for ctx_expand recovery
100pub(crate) fn persist_conversation(content: &str) -> Option<String> {
101    let path = persist_with_min(content, "conv", 1)?;
102    Path::new(&path)
103        .file_name()
104        .and_then(|name| name.to_str())
105        .map(str::to_owned)
106}
107
108/// Persist a JSON crusher's verbatim original (#936) under the `json_` prefix and
109/// return its `{state}/tee/json_{hash}.log` handle. Used by the lossy crush stage
110/// so a dropped column is always recoverable out-of-band via [`resolve_tee`] /
111/// `ctx_expand`, never reconstructed from the (lossy) text. Shares the
112/// content-address and best-effort write contract of [`persist`], so the embedded
113/// handle stays deterministic (cache-safe).
114pub(crate) fn persist_json(content: &str) -> Option<String> {
115    persist_with(content, "json")
116}
117
118/// Persist a tabular (CSV/TSV) crusher's verbatim original (#982) under the
119/// `tbl_` prefix and return its `{state}/tee/tbl_{hash}.log` handle. Used by the
120/// lossy column-drop stage so a dropped column is always recoverable out-of-band
121/// via [`resolve_tee`] / `ctx_expand`, never reconstructed from the (lossy) text.
122/// Shares the content-address and best-effort write contract of [`persist`].
123pub(crate) fn persist_tabular(content: &str) -> Option<String> {
124    persist_with(content, "tbl")
125}
126
127/// Persist a YAML crusher's verbatim original (#985) under the `yaml_` prefix and
128/// return its `{state}/tee/yaml_{hash}.log` handle. Used by the lossy column-drop
129/// stage so a dropped column is always recoverable out-of-band via [`resolve_tee`]
130/// / `ctx_expand`, never reconstructed from the (lossy) text. Shares the
131/// content-address and best-effort write contract of [`persist`].
132pub(crate) fn persist_yaml(content: &str) -> Option<String> {
133    persist_with(content, "yaml")
134}
135
136/// Persist an HTML page's verbatim original (#1124) under the `html_` prefix
137/// and return its `{state}/tee/html_{hash}.log` handle. The extracted markdown
138/// is the compressed form; the full HTML is recoverable via [`resolve_tee`] /
139/// `ctx_expand`. Shares the content-address and best-effort write contract of
140/// [`persist`].
141pub(crate) fn persist_html(content: &str) -> Option<String> {
142    persist_with(content, "html")
143}
144
145fn persist_with(content: &str, prefix: &str) -> Option<String> {
146    persist_with_min(content, prefix, MIN_TEE_BYTES)
147}
148
149fn persist_with_min(content: &str, prefix: &str, min_bytes: usize) -> Option<String> {
150    if content.len() < min_bytes {
151        return None;
152    }
153    let path = tee_path(content, prefix)?;
154    let handle = path.to_string_lossy().to_string();
155
156    if !path.exists() {
157        if let Some(dir) = path.parent()
158            && std::fs::create_dir_all(dir).is_ok()
159        {
160            maybe_cleanup(dir);
161        }
162        // Same redaction the shell tee applies, so a recovered original can never
163        // re-introduce a secret the live turn would also have masked.
164        let masked = crate::core::redaction::redact_text(content);
165        let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
166        if std::fs::write(&path, redacted).is_ok() {
167            #[cfg(unix)]
168            {
169                use std::os::unix::fs::PermissionsExt;
170                let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
171            }
172        }
173    }
174    if path.is_file() {
175        let source_tool = match prefix {
176            "json" | "tbl" | "yaml" => "ctx_read",
177            "html" => "ctx_shell",
178            _ => "proxy",
179        };
180        crate::core::relevance_tracker::register_compressed(
181            handle.clone(),
182            content,
183            source_tool,
184            crate::core::tokens::count_tokens(content),
185            0,
186        );
187    }
188    Some(handle)
189}
190
191fn is_hex(s: &str, len: usize) -> bool {
192    s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
193}
194
195/// Canonical `{prefix}_{16hex}.log` name for a proxy / json / tbl / bare-hash id,
196/// or `None`. A bare 16-hex id defaults to the `proxy_` store (back-compat: that
197/// is the only form pre-#936 stubs carry).
198fn canonical_tee_name(name: &str) -> Option<String> {
199    let stem = name.strip_suffix(".log").unwrap_or(name);
200    if let Some(hash) = stem.strip_prefix("proxy_") {
201        return is_hex(hash, TEE_HASH_LEN).then(|| format!("proxy_{hash}.log"));
202    }
203    if let Some(hash) = stem.strip_prefix("conv_") {
204        return is_hex(hash, TEE_HASH_LEN).then(|| format!("conv_{hash}.log"));
205    }
206    if let Some(hash) = stem.strip_prefix("json_") {
207        return is_hex(hash, TEE_HASH_LEN).then(|| format!("json_{hash}.log"));
208    }
209    if let Some(hash) = stem.strip_prefix("tbl_") {
210        return is_hex(hash, TEE_HASH_LEN).then(|| format!("tbl_{hash}.log"));
211    }
212    if let Some(hash) = stem.strip_prefix("yaml_") {
213        return is_hex(hash, TEE_HASH_LEN).then(|| format!("yaml_{hash}.log"));
214    }
215    is_hex(stem, TEE_HASH_LEN).then(|| format!("proxy_{stem}.log"))
216}
217
218/// True for a shell tee basename `<slug>_<8hex>.log` (`shell::redact::save_tee`):
219/// ends in `.log`, the whole basename is safe (`[A-Za-z0-9_-]`), and the **last**
220/// `_`-segment is exactly 8 hex. The slug itself may contain `_`, so the hash is
221/// matched as the suffix — never the first segment (the documented parsing trap).
222fn is_shell_tee_name(name: &str) -> bool {
223    let Some(stem) = name.strip_suffix(".log") else {
224        return false;
225    };
226    if !stem
227        .bytes()
228        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
229    {
230        return false;
231    }
232    match stem.rsplit_once('_') {
233        Some((slug, hash)) => !slug.is_empty() && is_hex(hash, SHELL_TEE_HASH_LEN),
234        None => false,
235    }
236}
237
238/// Resolve a retrieval `id` back to a file in the shared `{state}/tee/` store.
239/// Accepts every handle form a stub or footer can carry, with a fixed precedence
240/// so the forms can never collide (#936):
241///
242/// 1. **Prefix forms** — `proxy_<16hex>(.log)`, `conv_<16hex>(.log)`,
243///    `json_<16hex>(.log)`, `tbl_<16hex>(.log)`, `yaml_<16hex>(.log)`, or a bare
244///    `<16hex>` (→ `proxy_`,
245///    back-compat). The proxy history-prune / live stubs and the JSON / tabular /
246///    YAML crushers' lossy originals.
247/// 2. **Shell-tee form** — `<slug>_<8hex>.log` (`save_tee`), so every compressed
248///    shell command's already-teed verbatim output is surgically retrievable.
249///
250/// The 16-vs-8 hex length already disambiguates the two classes; the explicit
251/// order documents intent. Security: only the *file name* is trusted — the path
252/// is always rebuilt under `{state}/tee/`, so a crafted `id` can never escape the
253/// store (no path traversal) and a non-tee id resolves to `None`.
254pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
255    let name = Path::new(id)
256        .file_name()
257        .and_then(|n| n.to_str())
258        .unwrap_or(id);
259    let canon =
260        canonical_tee_name(name).or_else(|| is_shell_tee_name(name).then(|| name.to_string()))?;
261    let path = crate::core::paths::state_dir()
262        .ok()?
263        .join("tee")
264        .join(canon);
265    path.is_file().then_some(path)
266}
267
268/// The in-band retrieval marker `<lc_expand:HASH>` for a CCR `handle` (#493).
269///
270/// `HASH` is the content hash already embedded in the tee handle, so a model can
271/// echo the marker verbatim and the proxy can recover the original via
272/// [`resolve_tee`] on the next turn. Pure (no I/O, no config) so it is trivially
273/// testable; returns `None` for a handle that is not a canonical tee path.
274pub(crate) fn inband_marker(handle: &str) -> Option<String> {
275    let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
276    let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
277    (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
278        .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
279}
280
281/// The in-band marker for `handle` **only when in-band CCR is enabled** (#493),
282/// else `None`. Stub sites use this to advertise an echo-able `<lc_expand:HASH>`
283/// solely in in-band mode: a normal (shared-filesystem) deployment keeps its
284/// path handle, so the model never sees a marker the proxy would not splice.
285///
286/// Reads the (process-cached) config; the surrounding stub path already does
287/// per-message tee I/O via [`persist`], so this adds no new I/O class.
288pub(crate) fn inband_locator(handle: &str) -> Option<String> {
289    crate::core::config::Config::load()
290        .proxy
291        .ccr_inband_enabled()
292        .then(|| inband_marker(handle))
293        .flatten()
294}
295
296/// Recover the verbatim original for a 16-hex CCR `hash` from the local tee
297/// store, or `None` when the hash is malformed or the file is gone (past TTL).
298fn recover(hash: &str) -> Option<String> {
299    if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
300        return None;
301    }
302    std::fs::read_to_string(resolve_tee(hash)?).ok()
303}
304
305/// Length of a LiteLLM gateway retrieval hash (#702): LiteLLM's headroom
306/// guardrail scans compressed text with `hash=([a-f0-9]{24})`
307/// (BerriAI/litellm#31681), so the marker must carry exactly 24 lowercase hex.
308pub(crate) const LITELLM_HASH_LEN: usize = 24;
309
310/// The 24-hex gateway retrieval hash for `content` (#702): the first 24 chars
311/// of the full blake3 hex. A strict extension of the 16-hex tee name
312/// ([`crate::core::hasher::hash_short`] is the same digest truncated to 16),
313/// so `hash[..16]` resolves the tee file while the extra 8 chars keep the
314/// marker collision-resistant on the gateway side. Pure — the marker embedding
315/// it stays byte-stable per content (#498).
316pub(crate) fn litellm_hash(content: &str) -> String {
317    blake3::hash(content.as_bytes()).to_hex()[..LITELLM_HASH_LEN].to_string()
318}
319
320/// Resolve a LiteLLM `hash=<24hex>` retrieval id (#702) back to the verbatim
321/// original in the tee store, for `GET /v1/retrieve/{hash}`. Shape-locked to
322/// LiteLLM's own regex (24 lowercase hex — uppercase or any other length is
323/// rejected, never coerced) so only a string the guardrail could actually have
324/// captured resolves; the first 16 chars are the `proxy_` tee content-address.
325pub(crate) fn retrieve_litellm(hash: &str) -> Option<String> {
326    if hash.len() != LITELLM_HASH_LEN
327        || !hash
328            .bytes()
329            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
330    {
331        return None;
332    }
333    std::fs::read_to_string(resolve_tee(&hash[..TEE_HASH_LEN])?).ok()
334}
335
336/// Replace every `<lc_expand:HASH>` marker in `s` with the verbatim original
337/// recovered from the local tee store. Returns `Some(spliced)` only when at
338/// least one marker resolved, else `None` (so the caller leaves the string —
339/// and therefore the request bytes — untouched).
340///
341/// An unresolvable marker (bad hash, or a file dropped past the 24h TTL) is left
342/// in place verbatim: the model still sees its own marker rather than a silent
343/// deletion, and a later turn can retry once the operator restores the file.
344/// The spliced content is inserted **raw** (not `<lc_safe>`-wrapped): this runs
345/// on the recent assistant turn the model echoed the marker into, which no proxy
346/// compressor rewrites, and the proxy has no global `<lc_safe>` strip — wrapping
347/// would instead leak the markers to the provider.
348fn splice_str(s: &str) -> Option<String> {
349    if !s.contains(EXPAND_OPEN) {
350        return None;
351    }
352    let mut out = String::with_capacity(s.len());
353    let mut rest = s;
354    let mut changed = false;
355    while let Some(pos) = rest.find(EXPAND_OPEN) {
356        let after = &rest[pos + EXPAND_OPEN.len()..];
357        match after.find(EXPAND_CLOSE) {
358            Some(end) => {
359                let hash = &after[..end];
360                if let Some(original) = recover(hash) {
361                    out.push_str(&rest[..pos]);
362                    out.push_str(&original);
363                    rest = &after[end + EXPAND_CLOSE.len_utf8()..];
364                    changed = true;
365                } else {
366                    // Keep the literal marker; resume scanning past this `<` so a
367                    // later valid marker in the same string is still spliced.
368                    out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
369                    rest = after;
370                }
371            }
372            // No closing `>`: nothing more can match — keep the remainder verbatim.
373            None => break,
374        }
375    }
376    out.push_str(rest);
377    changed.then_some(out)
378}
379
380/// Splice in-band `<lc_expand:HASH>` markers throughout a parsed request body
381/// (#493), replacing each with the verbatim original recovered from the local
382/// tee store. Recurses over every JSON string (object values and array items).
383///
384/// Returns `true` iff at least one marker was spliced. A request with no marker
385/// is left **byte-identical** (the function never allocates a replacement), so a
386/// marker-less turn never perturbs the provider prompt-cache prefix — the splice
387/// only ever changes the bytes the model explicitly asked to expand.
388pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
389    match value {
390        Value::String(s) => {
391            if let Some(spliced) = splice_str(s) {
392                *s = spliced;
393                true
394            } else {
395                false
396            }
397        }
398        Value::Array(items) => {
399            let mut changed = false;
400            for item in items {
401                changed |= splice_inband_in_place(item);
402            }
403            changed
404        }
405        Value::Object(map) => {
406            let mut changed = false;
407            for (_, v) in map.iter_mut() {
408                changed |= splice_inband_in_place(v);
409            }
410            changed
411        }
412        _ => false,
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn big(seed: &str) -> String {
421        format!("{seed}\n").repeat(40)
422    }
423
424    #[test]
425    fn handle_is_content_addressed_and_deterministic() {
426        let _lock = crate::core::data_dir::test_env_lock();
427        let content = big("file body line");
428        let a = persist(&content).expect("persisted");
429        let b = persist(&content).expect("persisted again");
430        assert_eq!(
431            a, b,
432            "same content must map to the same handle (cache-safe)"
433        );
434        assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
435
436        let other = persist(&big("different body")).expect("persisted");
437        assert_ne!(a, other, "different content must get a different handle");
438    }
439
440    #[test]
441    fn persisted_original_is_recoverable() {
442        let _lock = crate::core::data_dir::test_env_lock();
443        let content = big("recoverable verbatim line");
444        let handle = persist(&content).expect("persisted");
445        let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
446        assert!(
447            on_disk.contains("recoverable verbatim line"),
448            "the verbatim original must be retrievable from the handle"
449        );
450    }
451
452    #[test]
453    fn small_content_gets_no_handle() {
454        let _lock = crate::core::data_dir::test_env_lock();
455        assert!(
456            persist("too small to bother").is_none(),
457            "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
458        );
459    }
460
461    #[test]
462    #[allow(clippy::case_sensitive_file_extension_comparisons)]
463    fn conversation_handle_is_compact_and_deterministic() {
464        let _lock = crate::core::data_dir::test_env_lock();
465        let a = persist_conversation("{\"role\":\"user\",\"content\":\"hello\"}")
466            .expect("conversation handle");
467        let b = persist_conversation("{\"role\":\"user\",\"content\":\"hello\"}")
468            .expect("conversation handle");
469        assert_eq!(a, b);
470        assert!(a.starts_with("conv_") && a.ends_with(".log"));
471    }
472
473    #[test]
474    fn conversation_handle_resolves_short_messages() {
475        let _lock = crate::core::data_dir::test_env_lock();
476        let handle = persist_conversation("short conversation message").expect("handle");
477        assert!(resolve_tee(&handle).is_some());
478    }
479
480    #[test]
481    fn conversation_original_is_recoverable() {
482        let _lock = crate::core::data_dir::test_env_lock();
483        let original = "{\"role\":\"tool\",\"content\":\"recover me\"}";
484        let handle = persist_conversation(original).expect("handle");
485        let path = resolve_tee(&handle).expect("resolved handle");
486        assert_eq!(std::fs::read_to_string(path).unwrap(), original);
487    }
488
489    #[test]
490    fn resolve_tee_accepts_every_stub_form() {
491        let _lock = crate::core::data_dir::test_env_lock();
492        let content = big("resolvable tee body");
493        let handle = persist(&content).expect("persisted");
494        let hash = crate::core::hasher::hash_short(&content);
495
496        // Full path, bare file name, proxy_<hash>, and bare <hash> all resolve to
497        // the same on-disk file — whatever the agent copied out of the stub.
498        for form in [
499            handle.clone(),
500            format!("proxy_{hash}.log"),
501            format!("proxy_{hash}"),
502            hash.clone(),
503        ] {
504            let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
505            assert_eq!(
506                resolved.to_string_lossy(),
507                handle,
508                "form {form} -> {handle}"
509            );
510        }
511    }
512
513    #[test]
514    fn resolve_tee_rejects_nontee_and_traversal_ids() {
515        let _lock = crate::core::data_dir::test_env_lock();
516        // No FS escape: a crafted path is reduced to its file name, which is not a
517        // valid proxy tee name, so it resolves to None instead of reading it.
518        assert!(resolve_tee("/etc/passwd").is_none());
519        assert!(resolve_tee("../../secret").is_none());
520        assert!(resolve_tee("proxy_nothex0000000.log").is_none());
521        // Right shape but no such file in the store.
522        assert!(resolve_tee("deadbeefdeadbeef").is_none());
523    }
524
525    #[test]
526    fn persist_json_is_distinct_prefix_and_resolvable() {
527        let _lock = crate::core::data_dir::test_env_lock();
528        let content = big("json crusher original");
529        let proxy = persist(&content).expect("proxy persisted");
530        let json = persist_json(&content).expect("json persisted");
531        assert!(
532            json.contains("json_"),
533            "json handle uses json_ prefix: {json}"
534        );
535        assert_ne!(
536            proxy, json,
537            "same content gets distinct files per producer prefix"
538        );
539
540        // The json_ handle resolves through the unified resolver in every form a
541        // stub / footer can carry: full path, bare file name, and bare json_id.
542        let hash = crate::core::hasher::hash_short(&content);
543        for form in [
544            json.clone(),
545            format!("json_{hash}.log"),
546            format!("json_{hash}"),
547        ] {
548            assert_eq!(
549                resolve_tee(&form)
550                    .expect("json form resolves")
551                    .to_string_lossy(),
552                json,
553                "json form {form} -> {json}"
554            );
555        }
556    }
557
558    #[test]
559    fn persist_tabular_is_distinct_prefix_and_resolvable() {
560        let _lock = crate::core::data_dir::test_env_lock();
561        let content = big("tabular crusher original");
562        let json = persist_json(&content).expect("json persisted");
563        let tbl = persist_tabular(&content).expect("tbl persisted");
564        assert!(
565            tbl.contains("tbl_"),
566            "tabular handle uses tbl_ prefix: {tbl}"
567        );
568        assert_ne!(
569            json, tbl,
570            "same content gets distinct files per producer prefix"
571        );
572
573        let hash = crate::core::hasher::hash_short(&content);
574        for form in [
575            tbl.clone(),
576            format!("tbl_{hash}.log"),
577            format!("tbl_{hash}"),
578        ] {
579            assert_eq!(
580                resolve_tee(&form)
581                    .expect("tbl form resolves")
582                    .to_string_lossy(),
583                tbl,
584                "tbl form {form} -> {tbl}"
585            );
586        }
587    }
588
589    #[test]
590    fn persist_yaml_is_distinct_prefix_and_resolvable() {
591        let _lock = crate::core::data_dir::test_env_lock();
592        let content = big("yaml crusher original");
593        let tbl = persist_tabular(&content).expect("tbl persisted");
594        let yaml = persist_yaml(&content).expect("yaml persisted");
595        assert!(
596            yaml.contains("yaml_"),
597            "yaml handle uses yaml_ prefix: {yaml}"
598        );
599        assert_ne!(
600            tbl, yaml,
601            "same content gets distinct files per producer prefix"
602        );
603
604        let hash = crate::core::hasher::hash_short(&content);
605        for form in [
606            yaml.clone(),
607            format!("yaml_{hash}.log"),
608            format!("yaml_{hash}"),
609        ] {
610            assert_eq!(
611                resolve_tee(&form)
612                    .expect("yaml form resolves")
613                    .to_string_lossy(),
614                yaml,
615                "yaml form {form} -> {yaml}"
616            );
617        }
618    }
619
620    #[test]
621    fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
622        let _lock = crate::core::data_dir::test_env_lock();
623        // A real shell command whose slug contains underscores: the hash is the
624        // last `_`-segment (8 hex), never the first — the parsing trap. The full
625        // verbatim output the shell already teed is now surgically retrievable.
626        let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
627            .expect("shell tee saved");
628        let name = std::path::Path::new(&path)
629            .file_name()
630            .and_then(|n| n.to_str())
631            .unwrap()
632            .to_string();
633        assert!(
634            is_shell_tee_name(&name),
635            "save_tee name must be recognized as a shell tee: {name}"
636        );
637        for form in [path.clone(), name] {
638            assert_eq!(
639                resolve_tee(&form)
640                    .expect("shell tee form resolves")
641                    .to_string_lossy(),
642                path,
643                "shell tee form -> {path}"
644            );
645        }
646    }
647
648    #[test]
649    fn resolve_tee_does_not_capture_reference_ids() {
650        let _lock = crate::core::data_dir::test_env_lock();
651        // A reference-store id (`ref_<16hex>`, no `.log`) must fall through the
652        // tee resolver so ctx_expand routes it to the reference store, not the
653        // tee store — the precedence guard for the unified retrieve ladder.
654        assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
655        // A bare 16-hex archive id with no backing tee file also stays None.
656        assert!(resolve_tee("0123456789abcdef").is_none());
657    }
658
659    #[test]
660    fn litellm_hash_is_24_lowercase_hex_and_extends_tee_hash() {
661        let content = big("gateway retrieval body");
662        let hash = litellm_hash(&content);
663        assert_eq!(hash.len(), LITELLM_HASH_LEN);
664        assert!(
665            hash.bytes()
666                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
667            "must match LiteLLM's [a-f0-9]{{24}} class: {hash}"
668        );
669        // Same blake3 digest as the tee name, longer prefix: the first 16 chars
670        // ARE the tee content-address, which is what makes retrieval work.
671        assert_eq!(hash[..16], crate::core::hasher::hash_short(&content));
672        // Pure function of content (#498): stable across calls.
673        assert_eq!(hash, litellm_hash(&content));
674    }
675
676    #[test]
677    fn retrieve_litellm_resolves_persisted_content() {
678        let _lock = crate::core::data_dir::test_env_lock();
679        let content = big("litellm retrievable original");
680        persist(&content).expect("persisted");
681        let recovered =
682            retrieve_litellm(&litellm_hash(&content)).expect("24-hex hash must resolve");
683        assert!(recovered.contains("litellm retrievable original"));
684    }
685
686    #[test]
687    fn retrieve_litellm_is_shape_locked_to_the_guardrail_regex() {
688        let _lock = crate::core::data_dir::test_env_lock();
689        let content = big("shape locked body");
690        persist(&content).expect("persisted");
691        let hash = litellm_hash(&content);
692
693        // 16-hex (our tee id), truncated, extended, uppercased, non-hex: all
694        // rejected — only the exact shape LiteLLM's regex captures resolves.
695        assert!(retrieve_litellm(&hash[..16]).is_none(), "16-hex rejected");
696        assert!(retrieve_litellm(&hash[..23]).is_none(), "23-hex rejected");
697        assert!(
698            retrieve_litellm(&format!("{hash}0")).is_none(),
699            "25-hex rejected"
700        );
701        assert!(
702            retrieve_litellm(&hash.to_uppercase()).is_none(),
703            "uppercase rejected (regex class is [a-f0-9])"
704        );
705        assert!(
706            retrieve_litellm("zzzzzzzzzzzzzzzzzzzzzzzz").is_none(),
707            "non-hex rejected"
708        );
709        // Traversal attempts die in resolve_tee's name canonicalization.
710        assert!(retrieve_litellm("../../etc/passwd00000000").is_none());
711        // Right shape, unknown content.
712        assert!(retrieve_litellm("0123456789abcdef01234567").is_none());
713    }
714
715    #[test]
716    fn inband_marker_is_derived_from_handle() {
717        let _lock = crate::core::data_dir::test_env_lock();
718        let content = big("inband marker body");
719        let handle = persist(&content).expect("persisted");
720        let hash = crate::core::hasher::hash_short(&content);
721        // The marker carries the same content hash the handle does, so a model can
722        // echo it and the proxy resolves it back to the very same tee file.
723        assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
724        // A non-tee handle has no marker.
725        assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
726    }
727
728    #[test]
729    fn splice_replaces_marker_with_verbatim_original() {
730        let _lock = crate::core::data_dir::test_env_lock();
731        let content = big("the historical verbatim line");
732        let handle = persist(&content).expect("persisted");
733        let marker = inband_marker(&handle).expect("marker");
734
735        let mut doc = serde_json::json!({
736            "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
737        });
738        assert!(splice_inband_in_place(&mut doc), "a marker must splice");
739        let spliced = doc["messages"][0]["content"].as_str().unwrap();
740        assert!(
741            spliced.contains("the historical verbatim line"),
742            "verbatim original must be spliced in: {spliced}"
743        );
744        assert!(
745            !spliced.contains("<lc_expand:"),
746            "the marker must be consumed, not left behind"
747        );
748    }
749
750    #[test]
751    fn splice_is_byte_identical_no_op_without_marker() {
752        let _lock = crate::core::data_dir::test_env_lock();
753        let mut doc = serde_json::json!({
754            "messages": [{ "role": "user", "content": "no marker here" }],
755            "system": "plain"
756        });
757        let before = doc.clone();
758        assert!(
759            !splice_inband_in_place(&mut doc),
760            "no marker → must report no change"
761        );
762        assert_eq!(
763            doc, before,
764            "marker-less body must stay byte-identical (cache-safe)"
765        );
766    }
767
768    #[test]
769    fn splice_keeps_unresolvable_marker_verbatim() {
770        let _lock = crate::core::data_dir::test_env_lock();
771        // Right shape, but no such file in the store → leave the model's marker in
772        // place rather than silently deleting it.
773        let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
774        assert!(!splice_inband_in_place(&mut doc));
775        assert_eq!(
776            doc["t"].as_str().unwrap(),
777            "before <lc_expand:deadbeefdeadbeef> after"
778        );
779    }
780
781    #[test]
782    fn splice_recurses_and_handles_multiple_markers() {
783        let _lock = crate::core::data_dir::test_env_lock();
784        let a = big("first recovered body");
785        let b = big("second recovered body");
786        let ma = inband_marker(&persist(&a).unwrap()).unwrap();
787        let mb = inband_marker(&persist(&b).unwrap()).unwrap();
788
789        // Two markers in one nested string, plus a deeper array item.
790        let mut doc = serde_json::json!({
791            "contents": [
792                { "parts": [{ "text": format!("{ma} and {mb}") }] }
793            ]
794        });
795        assert!(splice_inband_in_place(&mut doc));
796        let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
797        assert!(text.contains("first recovered body"));
798        assert!(text.contains("second recovered body"));
799        assert!(!text.contains("<lc_expand:"));
800    }
801}