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/// `json` for the JSON crusher's lossy originals, #936) yet keeps one shared
55/// store + one resolver ([`resolve_tee`]).
56fn tee_path(content: &str, prefix: &str) -> Option<PathBuf> {
57    let dir = crate::core::paths::state_dir().ok()?.join("tee");
58    let hash = crate::core::hasher::hash_short(content);
59    Some(dir.join(format!("{prefix}_{hash}.log")))
60}
61
62/// Run the shared 24h TTL cleanup at most once per [`CLEANUP_INTERVAL_SECS`].
63fn maybe_cleanup(tee_dir: &Path) {
64    static LAST: AtomicU64 = AtomicU64::new(0);
65    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
66        return;
67    };
68    let now = now.as_secs();
69    let last = LAST.load(Ordering::Relaxed);
70    if now.saturating_sub(last) < CLEANUP_INTERVAL_SECS {
71        return;
72    }
73    // Only one thread wins the slot; the rest skip until the next interval.
74    if LAST
75        .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
76        .is_ok()
77    {
78        crate::shell::cleanup_old_tee_logs(tee_dir);
79    }
80}
81
82/// Persist `content` verbatim (best-effort, secret-redacted) to the
83/// content-addressed tee store and return its retrieval handle (the absolute
84/// path). Returns `None` only when `content` is below [`MIN_TEE_BYTES`] or the
85/// state dir can't be resolved — never because the *write* failed, so the
86/// returned handle is a pure function of the content and the embedding stub
87/// stays deterministic. Re-persisting identical content is idempotent: same
88/// content → same path → the existing file is left untouched.
89pub(crate) fn persist(content: &str) -> Option<String> {
90    persist_with(content, "proxy")
91}
92
93/// Persist a JSON crusher's verbatim original (#936) under the `json_` prefix and
94/// return its `{state}/tee/json_{hash}.log` handle. Used by the lossy crush stage
95/// so a dropped column is always recoverable out-of-band via [`resolve_tee`] /
96/// `ctx_expand`, never reconstructed from the (lossy) text. Shares the
97/// content-address and best-effort write contract of [`persist`], so the embedded
98/// handle stays deterministic (cache-safe).
99pub(crate) fn persist_json(content: &str) -> Option<String> {
100    persist_with(content, "json")
101}
102
103/// Persist a tabular (CSV/TSV) crusher's verbatim original (#982) under the
104/// `tbl_` prefix and return its `{state}/tee/tbl_{hash}.log` handle. Used by the
105/// lossy column-drop stage so a dropped column is always recoverable out-of-band
106/// via [`resolve_tee`] / `ctx_expand`, never reconstructed from the (lossy) text.
107/// Shares the content-address and best-effort write contract of [`persist`].
108pub(crate) fn persist_tabular(content: &str) -> Option<String> {
109    persist_with(content, "tbl")
110}
111
112/// Persist a YAML crusher's verbatim original (#985) under the `yaml_` prefix and
113/// return its `{state}/tee/yaml_{hash}.log` handle. Used by the lossy column-drop
114/// stage so a dropped column is always recoverable out-of-band via [`resolve_tee`]
115/// / `ctx_expand`, never reconstructed from the (lossy) text. Shares the
116/// content-address and best-effort write contract of [`persist`].
117pub(crate) fn persist_yaml(content: &str) -> Option<String> {
118    persist_with(content, "yaml")
119}
120
121fn persist_with(content: &str, prefix: &str) -> Option<String> {
122    if content.len() < MIN_TEE_BYTES {
123        return None;
124    }
125    let path = tee_path(content, prefix)?;
126    let handle = path.to_string_lossy().to_string();
127
128    if !path.exists() {
129        if let Some(dir) = path.parent()
130            && std::fs::create_dir_all(dir).is_ok()
131        {
132            maybe_cleanup(dir);
133        }
134        // Same redaction the shell tee applies, so a recovered original can never
135        // re-introduce a secret the live turn would also have masked.
136        let masked = crate::core::redaction::redact_text(content);
137        let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
138        if std::fs::write(&path, redacted).is_ok() {
139            #[cfg(unix)]
140            {
141                use std::os::unix::fs::PermissionsExt;
142                let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
143            }
144        }
145    }
146    Some(handle)
147}
148
149fn is_hex(s: &str, len: usize) -> bool {
150    s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
151}
152
153/// Canonical `{prefix}_{16hex}.log` name for a proxy / json / tbl / bare-hash id,
154/// or `None`. A bare 16-hex id defaults to the `proxy_` store (back-compat: that
155/// is the only form pre-#936 stubs carry).
156fn canonical_tee_name(name: &str) -> Option<String> {
157    let stem = name.strip_suffix(".log").unwrap_or(name);
158    if let Some(hash) = stem.strip_prefix("proxy_") {
159        return is_hex(hash, TEE_HASH_LEN).then(|| format!("proxy_{hash}.log"));
160    }
161    if let Some(hash) = stem.strip_prefix("json_") {
162        return is_hex(hash, TEE_HASH_LEN).then(|| format!("json_{hash}.log"));
163    }
164    if let Some(hash) = stem.strip_prefix("tbl_") {
165        return is_hex(hash, TEE_HASH_LEN).then(|| format!("tbl_{hash}.log"));
166    }
167    if let Some(hash) = stem.strip_prefix("yaml_") {
168        return is_hex(hash, TEE_HASH_LEN).then(|| format!("yaml_{hash}.log"));
169    }
170    is_hex(stem, TEE_HASH_LEN).then(|| format!("proxy_{stem}.log"))
171}
172
173/// True for a shell tee basename `<slug>_<8hex>.log` (`shell::redact::save_tee`):
174/// ends in `.log`, the whole basename is safe (`[A-Za-z0-9_-]`), and the **last**
175/// `_`-segment is exactly 8 hex. The slug itself may contain `_`, so the hash is
176/// matched as the suffix — never the first segment (the documented parsing trap).
177fn is_shell_tee_name(name: &str) -> bool {
178    let Some(stem) = name.strip_suffix(".log") else {
179        return false;
180    };
181    if !stem
182        .bytes()
183        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
184    {
185        return false;
186    }
187    match stem.rsplit_once('_') {
188        Some((slug, hash)) => !slug.is_empty() && is_hex(hash, SHELL_TEE_HASH_LEN),
189        None => false,
190    }
191}
192
193/// Resolve a retrieval `id` back to a file in the shared `{state}/tee/` store.
194/// Accepts every handle form a stub or footer can carry, with a fixed precedence
195/// so the forms can never collide (#936):
196///
197/// 1. **Prefix forms** — `proxy_<16hex>(.log)`, `json_<16hex>(.log)`,
198///    `tbl_<16hex>(.log)`, `yaml_<16hex>(.log)`, or a bare `<16hex>` (→ `proxy_`,
199///    back-compat). The proxy history-prune / live stubs and the JSON / tabular /
200///    YAML crushers' lossy originals.
201/// 2. **Shell-tee form** — `<slug>_<8hex>.log` (`save_tee`), so every compressed
202///    shell command's already-teed verbatim output is surgically retrievable.
203///
204/// The 16-vs-8 hex length already disambiguates the two classes; the explicit
205/// order documents intent. Security: only the *file name* is trusted — the path
206/// is always rebuilt under `{state}/tee/`, so a crafted `id` can never escape the
207/// store (no path traversal) and a non-tee id resolves to `None`.
208pub(crate) fn resolve_tee(id: &str) -> Option<PathBuf> {
209    let name = Path::new(id)
210        .file_name()
211        .and_then(|n| n.to_str())
212        .unwrap_or(id);
213    let canon =
214        canonical_tee_name(name).or_else(|| is_shell_tee_name(name).then(|| name.to_string()))?;
215    let path = crate::core::paths::state_dir()
216        .ok()?
217        .join("tee")
218        .join(canon);
219    path.is_file().then_some(path)
220}
221
222/// The in-band retrieval marker `<lc_expand:HASH>` for a CCR `handle` (#493).
223///
224/// `HASH` is the content hash already embedded in the tee handle, so a model can
225/// echo the marker verbatim and the proxy can recover the original via
226/// [`resolve_tee`] on the next turn. Pure (no I/O, no config) so it is trivially
227/// testable; returns `None` for a handle that is not a canonical tee path.
228pub(crate) fn inband_marker(handle: &str) -> Option<String> {
229    let name = Path::new(handle).file_name().and_then(|n| n.to_str())?;
230    let hash = name.strip_prefix("proxy_")?.strip_suffix(".log")?;
231    (hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
232        .then(|| format!("{EXPAND_OPEN}{hash}{EXPAND_CLOSE}"))
233}
234
235/// The in-band marker for `handle` **only when in-band CCR is enabled** (#493),
236/// else `None`. Stub sites use this to advertise an echo-able `<lc_expand:HASH>`
237/// solely in in-band mode: a normal (shared-filesystem) deployment keeps its
238/// path handle, so the model never sees a marker the proxy would not splice.
239///
240/// Reads the (process-cached) config; the surrounding stub path already does
241/// per-message tee I/O via [`persist`], so this adds no new I/O class.
242pub(crate) fn inband_locator(handle: &str) -> Option<String> {
243    crate::core::config::Config::load()
244        .proxy
245        .ccr_inband_enabled()
246        .then(|| inband_marker(handle))
247        .flatten()
248}
249
250/// Recover the verbatim original for a 16-hex CCR `hash` from the local tee
251/// store, or `None` when the hash is malformed or the file is gone (past TTL).
252fn recover(hash: &str) -> Option<String> {
253    if hash.len() != 16 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
254        return None;
255    }
256    std::fs::read_to_string(resolve_tee(hash)?).ok()
257}
258
259/// Replace every `<lc_expand:HASH>` marker in `s` with the verbatim original
260/// recovered from the local tee store. Returns `Some(spliced)` only when at
261/// least one marker resolved, else `None` (so the caller leaves the string —
262/// and therefore the request bytes — untouched).
263///
264/// An unresolvable marker (bad hash, or a file dropped past the 24h TTL) is left
265/// in place verbatim: the model still sees its own marker rather than a silent
266/// deletion, and a later turn can retry once the operator restores the file.
267/// The spliced content is inserted **raw** (not `<lc_safe>`-wrapped): this runs
268/// on the recent assistant turn the model echoed the marker into, which no proxy
269/// compressor rewrites, and the proxy has no global `<lc_safe>` strip — wrapping
270/// would instead leak the markers to the provider.
271fn splice_str(s: &str) -> Option<String> {
272    if !s.contains(EXPAND_OPEN) {
273        return None;
274    }
275    let mut out = String::with_capacity(s.len());
276    let mut rest = s;
277    let mut changed = false;
278    while let Some(pos) = rest.find(EXPAND_OPEN) {
279        let after = &rest[pos + EXPAND_OPEN.len()..];
280        match after.find(EXPAND_CLOSE) {
281            Some(end) => {
282                let hash = &after[..end];
283                if let Some(original) = recover(hash) {
284                    out.push_str(&rest[..pos]);
285                    out.push_str(&original);
286                    rest = &after[end + EXPAND_CLOSE.len_utf8()..];
287                    changed = true;
288                } else {
289                    // Keep the literal marker; resume scanning past this `<` so a
290                    // later valid marker in the same string is still spliced.
291                    out.push_str(&rest[..pos + EXPAND_OPEN.len()]);
292                    rest = after;
293                }
294            }
295            // No closing `>`: nothing more can match — keep the remainder verbatim.
296            None => break,
297        }
298    }
299    out.push_str(rest);
300    changed.then_some(out)
301}
302
303/// Splice in-band `<lc_expand:HASH>` markers throughout a parsed request body
304/// (#493), replacing each with the verbatim original recovered from the local
305/// tee store. Recurses over every JSON string (object values and array items).
306///
307/// Returns `true` iff at least one marker was spliced. A request with no marker
308/// is left **byte-identical** (the function never allocates a replacement), so a
309/// marker-less turn never perturbs the provider prompt-cache prefix — the splice
310/// only ever changes the bytes the model explicitly asked to expand.
311pub(crate) fn splice_inband_in_place(value: &mut Value) -> bool {
312    match value {
313        Value::String(s) => {
314            if let Some(spliced) = splice_str(s) {
315                *s = spliced;
316                true
317            } else {
318                false
319            }
320        }
321        Value::Array(items) => {
322            let mut changed = false;
323            for item in items {
324                changed |= splice_inband_in_place(item);
325            }
326            changed
327        }
328        Value::Object(map) => {
329            let mut changed = false;
330            for (_, v) in map.iter_mut() {
331                changed |= splice_inband_in_place(v);
332            }
333            changed
334        }
335        _ => false,
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn big(seed: &str) -> String {
344        format!("{seed}\n").repeat(40)
345    }
346
347    #[test]
348    fn handle_is_content_addressed_and_deterministic() {
349        let _lock = crate::core::data_dir::test_env_lock();
350        let content = big("file body line");
351        let a = persist(&content).expect("persisted");
352        let b = persist(&content).expect("persisted again");
353        assert_eq!(
354            a, b,
355            "same content must map to the same handle (cache-safe)"
356        );
357        assert!(a.contains("proxy_"), "handle is a proxy tee path: {a}");
358
359        let other = persist(&big("different body")).expect("persisted");
360        assert_ne!(a, other, "different content must get a different handle");
361    }
362
363    #[test]
364    fn persisted_original_is_recoverable() {
365        let _lock = crate::core::data_dir::test_env_lock();
366        let content = big("recoverable verbatim line");
367        let handle = persist(&content).expect("persisted");
368        let on_disk = std::fs::read_to_string(&handle).expect("tee file readable");
369        assert!(
370            on_disk.contains("recoverable verbatim line"),
371            "the verbatim original must be retrievable from the handle"
372        );
373    }
374
375    #[test]
376    fn small_content_gets_no_handle() {
377        let _lock = crate::core::data_dir::test_env_lock();
378        assert!(
379            persist("too small to bother").is_none(),
380            "below MIN_TEE_BYTES there is no handle (the caller keeps its plain stub)"
381        );
382    }
383
384    #[test]
385    fn resolve_tee_accepts_every_stub_form() {
386        let _lock = crate::core::data_dir::test_env_lock();
387        let content = big("resolvable tee body");
388        let handle = persist(&content).expect("persisted");
389        let hash = crate::core::hasher::hash_short(&content);
390
391        // Full path, bare file name, proxy_<hash>, and bare <hash> all resolve to
392        // the same on-disk file — whatever the agent copied out of the stub.
393        for form in [
394            handle.clone(),
395            format!("proxy_{hash}.log"),
396            format!("proxy_{hash}"),
397            hash.clone(),
398        ] {
399            let resolved = resolve_tee(&form).unwrap_or_else(|| panic!("must resolve {form}"));
400            assert_eq!(
401                resolved.to_string_lossy(),
402                handle,
403                "form {form} -> {handle}"
404            );
405        }
406    }
407
408    #[test]
409    fn resolve_tee_rejects_nontee_and_traversal_ids() {
410        let _lock = crate::core::data_dir::test_env_lock();
411        // No FS escape: a crafted path is reduced to its file name, which is not a
412        // valid proxy tee name, so it resolves to None instead of reading it.
413        assert!(resolve_tee("/etc/passwd").is_none());
414        assert!(resolve_tee("../../secret").is_none());
415        assert!(resolve_tee("proxy_nothex0000000.log").is_none());
416        // Right shape but no such file in the store.
417        assert!(resolve_tee("deadbeefdeadbeef").is_none());
418    }
419
420    #[test]
421    fn persist_json_is_distinct_prefix_and_resolvable() {
422        let _lock = crate::core::data_dir::test_env_lock();
423        let content = big("json crusher original");
424        let proxy = persist(&content).expect("proxy persisted");
425        let json = persist_json(&content).expect("json persisted");
426        assert!(
427            json.contains("json_"),
428            "json handle uses json_ prefix: {json}"
429        );
430        assert_ne!(
431            proxy, json,
432            "same content gets distinct files per producer prefix"
433        );
434
435        // The json_ handle resolves through the unified resolver in every form a
436        // stub / footer can carry: full path, bare file name, and bare json_id.
437        let hash = crate::core::hasher::hash_short(&content);
438        for form in [
439            json.clone(),
440            format!("json_{hash}.log"),
441            format!("json_{hash}"),
442        ] {
443            assert_eq!(
444                resolve_tee(&form)
445                    .expect("json form resolves")
446                    .to_string_lossy(),
447                json,
448                "json form {form} -> {json}"
449            );
450        }
451    }
452
453    #[test]
454    fn persist_tabular_is_distinct_prefix_and_resolvable() {
455        let _lock = crate::core::data_dir::test_env_lock();
456        let content = big("tabular crusher original");
457        let json = persist_json(&content).expect("json persisted");
458        let tbl = persist_tabular(&content).expect("tbl persisted");
459        assert!(
460            tbl.contains("tbl_"),
461            "tabular handle uses tbl_ prefix: {tbl}"
462        );
463        assert_ne!(
464            json, tbl,
465            "same content gets distinct files per producer prefix"
466        );
467
468        let hash = crate::core::hasher::hash_short(&content);
469        for form in [
470            tbl.clone(),
471            format!("tbl_{hash}.log"),
472            format!("tbl_{hash}"),
473        ] {
474            assert_eq!(
475                resolve_tee(&form)
476                    .expect("tbl form resolves")
477                    .to_string_lossy(),
478                tbl,
479                "tbl form {form} -> {tbl}"
480            );
481        }
482    }
483
484    #[test]
485    fn persist_yaml_is_distinct_prefix_and_resolvable() {
486        let _lock = crate::core::data_dir::test_env_lock();
487        let content = big("yaml crusher original");
488        let tbl = persist_tabular(&content).expect("tbl persisted");
489        let yaml = persist_yaml(&content).expect("yaml persisted");
490        assert!(
491            yaml.contains("yaml_"),
492            "yaml handle uses yaml_ prefix: {yaml}"
493        );
494        assert_ne!(
495            tbl, yaml,
496            "same content gets distinct files per producer prefix"
497        );
498
499        let hash = crate::core::hasher::hash_short(&content);
500        for form in [
501            yaml.clone(),
502            format!("yaml_{hash}.log"),
503            format!("yaml_{hash}"),
504        ] {
505            assert_eq!(
506                resolve_tee(&form)
507                    .expect("yaml form resolves")
508                    .to_string_lossy(),
509                yaml,
510                "yaml form {form} -> {yaml}"
511            );
512        }
513    }
514
515    #[test]
516    fn resolve_tee_resolves_shell_tee_with_underscored_slug() {
517        let _lock = crate::core::data_dir::test_env_lock();
518        // A real shell command whose slug contains underscores: the hash is the
519        // last `_`-segment (8 hex), never the first — the parsing trap. The full
520        // verbatim output the shell already teed is now surgically retrievable.
521        let path = crate::shell::save_tee("gh api /repos/foo/bar", &big("api row"))
522            .expect("shell tee saved");
523        let name = std::path::Path::new(&path)
524            .file_name()
525            .and_then(|n| n.to_str())
526            .unwrap()
527            .to_string();
528        assert!(
529            is_shell_tee_name(&name),
530            "save_tee name must be recognized as a shell tee: {name}"
531        );
532        for form in [path.clone(), name] {
533            assert_eq!(
534                resolve_tee(&form)
535                    .expect("shell tee form resolves")
536                    .to_string_lossy(),
537                path,
538                "shell tee form -> {path}"
539            );
540        }
541    }
542
543    #[test]
544    fn resolve_tee_does_not_capture_reference_ids() {
545        let _lock = crate::core::data_dir::test_env_lock();
546        // A reference-store id (`ref_<16hex>`, no `.log`) must fall through the
547        // tee resolver so ctx_expand routes it to the reference store, not the
548        // tee store — the precedence guard for the unified retrieve ladder.
549        assert!(resolve_tee("ref_deadbeefcafef00d").is_none());
550        // A bare 16-hex archive id with no backing tee file also stays None.
551        assert!(resolve_tee("0123456789abcdef").is_none());
552    }
553
554    #[test]
555    fn inband_marker_is_derived_from_handle() {
556        let _lock = crate::core::data_dir::test_env_lock();
557        let content = big("inband marker body");
558        let handle = persist(&content).expect("persisted");
559        let hash = crate::core::hasher::hash_short(&content);
560        // The marker carries the same content hash the handle does, so a model can
561        // echo it and the proxy resolves it back to the very same tee file.
562        assert_eq!(inband_marker(&handle), Some(format!("<lc_expand:{hash}>")));
563        // A non-tee handle has no marker.
564        assert!(inband_marker("/tmp/not-a-tee.txt").is_none());
565    }
566
567    #[test]
568    fn splice_replaces_marker_with_verbatim_original() {
569        let _lock = crate::core::data_dir::test_env_lock();
570        let content = big("the historical verbatim line");
571        let handle = persist(&content).expect("persisted");
572        let marker = inband_marker(&handle).expect("marker");
573
574        let mut doc = serde_json::json!({
575            "messages": [{ "role": "assistant", "content": format!("recall {marker} please") }]
576        });
577        assert!(splice_inband_in_place(&mut doc), "a marker must splice");
578        let spliced = doc["messages"][0]["content"].as_str().unwrap();
579        assert!(
580            spliced.contains("the historical verbatim line"),
581            "verbatim original must be spliced in: {spliced}"
582        );
583        assert!(
584            !spliced.contains("<lc_expand:"),
585            "the marker must be consumed, not left behind"
586        );
587    }
588
589    #[test]
590    fn splice_is_byte_identical_no_op_without_marker() {
591        let _lock = crate::core::data_dir::test_env_lock();
592        let mut doc = serde_json::json!({
593            "messages": [{ "role": "user", "content": "no marker here" }],
594            "system": "plain"
595        });
596        let before = doc.clone();
597        assert!(
598            !splice_inband_in_place(&mut doc),
599            "no marker → must report no change"
600        );
601        assert_eq!(
602            doc, before,
603            "marker-less body must stay byte-identical (cache-safe)"
604        );
605    }
606
607    #[test]
608    fn splice_keeps_unresolvable_marker_verbatim() {
609        let _lock = crate::core::data_dir::test_env_lock();
610        // Right shape, but no such file in the store → leave the model's marker in
611        // place rather than silently deleting it.
612        let mut doc = serde_json::json!({ "t": "before <lc_expand:deadbeefdeadbeef> after" });
613        assert!(!splice_inband_in_place(&mut doc));
614        assert_eq!(
615            doc["t"].as_str().unwrap(),
616            "before <lc_expand:deadbeefdeadbeef> after"
617        );
618    }
619
620    #[test]
621    fn splice_recurses_and_handles_multiple_markers() {
622        let _lock = crate::core::data_dir::test_env_lock();
623        let a = big("first recovered body");
624        let b = big("second recovered body");
625        let ma = inband_marker(&persist(&a).unwrap()).unwrap();
626        let mb = inband_marker(&persist(&b).unwrap()).unwrap();
627
628        // Two markers in one nested string, plus a deeper array item.
629        let mut doc = serde_json::json!({
630            "contents": [
631                { "parts": [{ "text": format!("{ma} and {mb}") }] }
632            ]
633        });
634        assert!(splice_inband_in_place(&mut doc));
635        let text = doc["contents"][0]["parts"][0]["text"].as_str().unwrap();
636        assert!(text.contains("first recovered body"));
637        assert!(text.contains("second recovered body"));
638        assert!(!text.contains("<lc_expand:"));
639    }
640}