Skip to main content

lean_ctx/proxy/
compress.rs

1use super::ccr;
2use crate::core::tokens::{COUNTING_FAMILY, TokenizerFamily, count_tokens_for};
3use crate::core::web::distill;
4
5/// Byte-ish budget for the research-prose squeeze (~5k tokens on English prose).
6/// Only oversized prose is truncated; the squeeze's main job is dedup + blank-collapse,
7/// not cutting.
8const RESEARCH_PROSE_CAP: usize = 20_000;
9const RESEARCH_PROSE_CAP_ENV: &str = "LEAN_CTX_RESEARCH_PROSE_CAP";
10
11fn research_prose_cap() -> usize {
12    std::env::var(RESEARCH_PROSE_CAP_ENV)
13        .ok()
14        .and_then(|v| v.trim().parse::<usize>().ok())
15        .filter(|cap| *cap > 0)
16        .unwrap_or(RESEARCH_PROSE_CAP)
17}
18
19/// Proxy compression funnel: routes a tool result to the right compressor.
20///
21/// 1. Already-cited research output (from `ctx_url_read` / the web layer) is kept
22///    verbatim — it is distilled and citation-stamped, so the shell pipeline must
23///    not touch its footer or claim markers.
24/// 2. Prose results (web fetches, doc reads, research MCP bridges) are squeezed
25///    by the prose-aware research compressor instead of the log/code-tuned shell
26///    engine.
27/// 3. Everything else (shell/build/search output) flows through the unified
28///    `compress_if_beneficial` pipeline. A `$ ...` command hint is extracted so
29///    the pattern engine gets the same routing as the CLI and MCP paths.
30pub fn compress_tool_result(content: &str, tool_name: Option<&str>) -> String {
31    compress_tool_result_for(content, tool_name, COUNTING_FAMILY)
32}
33
34pub fn compress_tool_result_for(
35    content: &str,
36    tool_name: Option<&str>,
37    family: TokenizerFamily,
38) -> String {
39    let compressed = compress_inner(content, tool_name, family);
40    attach_ccr(content, compressed, CcrAudience::Local)
41}
42
43/// [`compress_tool_result`] for the `/v1/compress` gateway contract (#702):
44/// identical compression, but a lossy result advertises its retrieval hash in
45/// LiteLLM's regex-locked `hash=<24hex>` form, so a gateway running the
46/// headroom-guardrail CCR loop (BerriAI/litellm#31681) can inject its retrieve
47/// tool and resolve the original via `GET /v1/retrieve/{hash}`.
48///
49/// Any ambient savings footer is stripped *before* the marker is attached:
50/// savings figures belong in the caller's structured `stats` (#498), while the
51/// marker is functional content that must survive as the last line.
52pub fn compress_tool_result_gateway(content: &str, tool_name: Option<&str>) -> String {
53    compress_tool_result_gateway_for(content, tool_name, COUNTING_FAMILY)
54}
55
56pub fn compress_tool_result_gateway_for(
57    content: &str,
58    tool_name: Option<&str>,
59    family: TokenizerFamily,
60) -> String {
61    let compressed = compress_inner(content, tool_name, family);
62    let clean = crate::core::protocol::strip_trailing_savings_footer(&compressed).to_string();
63    attach_ccr(content, clean, CcrAudience::Gateway)
64}
65
66/// Who reads a CCR stub: a local agent (path handle / in-band marker) or a
67/// remote gateway whose agentic loop scans for `hash=<24hex>` (#702).
68#[derive(Clone, Copy, PartialEq)]
69enum CcrAudience {
70    Local,
71    Gateway,
72}
73
74/// Make a live-compressed `tool_result` non-lossy (#482): when compression
75/// removed a meaningful amount, tee the verbatim original to the shared
76/// content-addressed store and append a deterministic recovery handle. The
77/// handle is a pure function of the content hash, so the rewritten result is
78/// byte-stable across turns and never invalidates the provider cache prefix
79/// (#448). Passthrough / verbatim results (no real shrink) keep their bytes.
80fn attach_ccr(original: &str, result: String, audience: CcrAudience) -> String {
81    use super::sticky_tools;
82    if original.len() < ccr::MIN_TEE_BYTES
83        || original.len().saturating_sub(result.len()) < ccr::MIN_TEE_BYTES
84    {
85        return result;
86    }
87    match ccr::persist(original) {
88        // Gateway (#702): the reader is a model behind a LiteLLM-style gateway.
89        // A local path is useless there; advertise the 24-hex retrieval hash in
90        // the exact shape the guardrail's marker regex captures. The hash is a
91        // pure function of the content, so the stub stays byte-stable (#498).
92        // The `[lean-ctx CCR:` prefix is deliberately NOT the savings-footer
93        // shape (`[lean-ctx: `): this line is functional content — a footer
94        // strip must never eat it.
95        Some(handle) if audience == CcrAudience::Gateway => {
96            sticky_tools::mark_ccr_active(0);
97            let hash = ccr::litellm_hash(original);
98            format!(
99                "{result}\n[lean-ctx CCR: full original elided to save tokens — call the \
100                 retrieve tool with hash={hash}, or read {handle} locally]"
101            )
102        }
103        Some(handle) => {
104            sticky_tools::mark_ccr_active(0);
105            match ccr::inband_locator(&handle) {
106                Some(marker) => format!(
107                    "{result}\n[lean-ctx: full original elided to save tokens — echo {marker} \
108                     on your next turn to get the verbatim original spliced back inline]"
109                ),
110                None => format!(
111                    "{result}\n[lean-ctx: full original at {handle} — read it directly (no MCP), or \
112                     ctx_expand(id=\"{handle}\", head=N|search=\"…\"|json_path=\"…\") for a slice]"
113                ),
114            }
115        }
116        None => result,
117    }
118}
119
120fn compress_inner(content: &str, tool_name: Option<&str>, family: TokenizerFamily) -> String {
121    if content.trim().is_empty() || content.len() < 200 {
122        return content.to_string();
123    }
124
125    // #479: lean-ctx's own MCP tools already applied their compression policy at
126    // the tool boundary — honouring `raw=true`/`bypass`, `<lc_safe>` spans and
127    // the configured aggressiveness. Their `raw` intent lives in the originating
128    // `tool_use` input, which is invisible here, so re-compressing on the wire
129    // would silently undo an explicit `raw=true` and double-compress everything
130    // else. Pass results from `ctx_*` tools through untouched.
131    if tool_name.is_some_and(is_lean_ctx_tool) {
132        return content.to_string();
133    }
134
135    // #709: honour explicit <lc_safe>…</lc_safe> spans on the proxy path too.
136    // Protected spans pass through verbatim; each unprotected segment flows back
137    // through the normal funnel (markers are stripped, so this never recurses).
138    if crate::core::protect::has_markers(content) {
139        return crate::core::protect::compress_preserving(content, |seg| {
140            compress_inner(seg, tool_name, family)
141        });
142    }
143
144    if is_cited_research_output(content) {
145        return content.to_string();
146    }
147
148    if extract_command_hint(content).is_none()
149        && looks_like_prose(content)
150        && let Some(out) = squeeze_research_prose(content, family)
151    {
152        return out;
153    }
154
155    let cmd = infer_command(content, tool_name);
156
157    // Proxy fidelity guard. A foreign shell tool gives us at most a generic
158    // command (`"shell"`) or none, so the engine's command-gated build/test
159    // verbatim guards never fire. When the *output* is unmistakably a build or
160    // test run, preserve it verbatim (bounded by safety-line-preserving
161    // truncation) so compiler errors, panics and test summaries reach the model
162    // intact — the exact signal a bug-fix task depends on.
163    let generic_command = cmd.is_empty() || cmd == "shell";
164    if generic_command
165        && (output_looks_like_test_run(content) || output_looks_like_build_failure(content))
166    {
167        return crate::shell::compress::engine::preserve_verbatim_pub_for(content, family);
168    }
169
170    crate::shell::compress::engine::compress_if_beneficial_for(&cmd, content, family)
171}
172
173/// Strong, ecosystem-spanning signals that an output is a *test run* (passing or
174/// failing). Conservative — matches the summary/result lines a bug-fix task must
175/// never lose. Only consulted on the proxy path when the real command is unknown.
176fn output_looks_like_test_run(content: &str) -> bool {
177    const NEEDLES: &[&str] = &[
178        "test result:",            // rust
179        "short test summary info", // pytest
180        " passed in ",             // pytest summary
181        " failed in ",             // pytest summary
182        "=== RUN",                 // go
183        "--- FAIL:",               // go
184        "--- PASS:",               // go
185        "Test Suites:",            // jest
186        " examples, ",             // rspec ("5 examples, 0 failures")
187        "FAILED",                  // generic test failure marker
188    ];
189    NEEDLES.iter().any(|n| content.contains(n))
190}
191
192/// Strong, specific signals of a build / compile / runtime failure across the
193/// major toolchains. Used only on the proxy path for generically-named tools so
194/// the failing diagnostics (paths, lines, messages) survive intact.
195fn output_looks_like_build_failure(content: &str) -> bool {
196    const NEEDLES: &[&str] = &[
197        "error[",                            // rustc (E0277 …)
198        ": error:",                          // gcc / clang "file.c:12:5: error:"
199        "fatal error:",                      // gcc / clang
200        "undefined reference to",            // linker
201        "panicked at",                       // rust runtime
202        "could not compile",                 // cargo
203        "Traceback (most recent call last)", // python
204        "AssertionError",                    // python / junit
205        "make: ***",                         // make
206        "Build FAILED",
207        "BUILD FAILED",
208        "Segmentation fault",
209    ];
210    NEEDLES.iter().any(|n| content.contains(n))
211}
212
213/// True when `content` is a lean-ctx web read: distilled body + citation footer
214/// (`Source: …\nSite: … · Retrieved: …`). Such output is re-compression-hostile.
215fn is_cited_research_output(content: &str) -> bool {
216    content.contains("· Retrieved: ") && content.contains("\nSource: ")
217}
218
219/// Code/shell symbols whose density cleanly separates source/logs from prose.
220const CODE_SYMBOLS: &str = "{}<>;=|\\$`";
221
222/// Conservative prose detector: substantial, letter-dense, low code-symbol, with
223/// real sentences and long lines. Code, logs, tables and JSON all fail this.
224fn looks_like_prose(content: &str) -> bool {
225    let sample: String = content.chars().take(4000).collect();
226    let total = sample.chars().count();
227    if total < 600 {
228        return false;
229    }
230    let total_f = total as f32;
231    let alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as f32;
232    let spaces = sample.chars().filter(|c| *c == ' ').count() as f32;
233    let symbols = sample.chars().filter(|c| CODE_SYMBOLS.contains(*c)).count() as f32;
234
235    if alpha / total_f < 0.6 || spaces / total_f < 0.12 || symbols / total_f > 0.06 {
236        return false;
237    }
238    if sample.matches(['.', '!', '?']).count() < 4 {
239        return false;
240    }
241
242    let non_empty: Vec<&str> = sample.lines().filter(|l| !l.trim().is_empty()).collect();
243    if non_empty.is_empty() {
244        return false;
245    }
246    let avg_len =
247        non_empty.iter().map(|l| l.chars().count()).sum::<usize>() as f32 / non_empty.len() as f32;
248    avg_len >= 40.0
249}
250
251/// Apply the prose squeeze, returning a footer-stamped result only when it
252/// actually saves tokens; otherwise `None` so the normal pipeline can try.
253fn squeeze_research_prose(content: &str, family: TokenizerFamily) -> Option<String> {
254    let before = count_tokens_for(content, family);
255    let squeezed = squeeze_research_prose_body(content);
256    if squeezed.trim().is_empty() {
257        return None;
258    }
259    let after = count_tokens_for(&squeezed, family);
260    if after + 2 >= before {
261        return None;
262    }
263    Some(crate::core::protocol::append_savings_with_info(
264        &squeezed,
265        before,
266        after,
267        Some("research"),
268        None,
269    ))
270}
271
272/// Choose the prose-squeeze body. Only when the content would actually be
273/// TRUNCATED (over the cap) do we upgrade from FIFO prefix truncation to
274/// extractive centrality ranking — which keeps the most representative sentences
275/// instead of just the first ones — via the cache-safe, memoized wire squeeze
276/// ([`crate::proxy::prose_ranker`]), so the cold→warm engine transition never
277/// changes a frozen-region rewrite (#448/#498). Below the cap the squeeze is a
278/// lossless dedup pass, so the cheaper truncating squeeze is used.
279fn squeeze_research_prose_body(content: &str) -> String {
280    let cap = research_prose_cap();
281    if content.len() > cap {
282        return super::prose_ranker::squeeze(content, cap);
283    }
284    distill::squeeze_prose(content, cap)
285}
286
287/// True when `name` refers to one of lean-ctx's own `ctx_*` MCP tools, whose
288/// results are already compressed at the tool boundary and must not be touched
289/// again by the proxy (#479).
290///
291/// Clients namespace MCP tools differently, so a plain `starts_with("ctx_")`
292/// misses the real-world callers: Claude Code (the reporter's setup) sends
293/// `mcp__lean-ctx__ctx_shell`, others use `lean-ctx:ctx_read`. Strip the client
294/// prefix down to the bare tool segment before matching.
295fn is_lean_ctx_tool(name: &str) -> bool {
296    let bare = name
297        .rsplit("__")
298        .next()
299        .unwrap_or(name)
300        .rsplit([':', '/', '.'])
301        .next()
302        .unwrap_or(name);
303    bare.starts_with("ctx_") || name.starts_with("ctx_")
304}
305
306fn infer_command(content: &str, tool_name: Option<&str>) -> String {
307    if let Some(cmd) = extract_command_hint(content) {
308        return cmd;
309    }
310
311    if let Some(name) = tool_name {
312        let nl = name.to_lowercase();
313        if nl.contains("bash") || nl.contains("shell") || nl.contains("terminal") {
314            return "shell".to_string();
315        }
316        if nl.contains("search") || nl.contains("grep") || nl.contains("find") {
317            return "grep".to_string();
318        }
319    }
320
321    String::new()
322}
323
324fn extract_command_hint(content: &str) -> Option<String> {
325    for line in content.lines().take(3) {
326        let trimmed = line.trim();
327        if let Some(cmd) = trimmed.strip_prefix("$ ") {
328            return Some(cmd.to_string());
329        }
330        if let Some(cmd) = trimmed.strip_prefix("% ") {
331            return Some(cmd.to_string());
332        }
333    }
334    None
335}
336
337#[cfg(test)]
338mod tests {
339    use super::ccr;
340    use super::{
341        RESEARCH_PROSE_CAP, RESEARCH_PROSE_CAP_ENV, compress_tool_result,
342        compress_tool_result_gateway, extract_command_hint, infer_command, looks_like_prose,
343        output_looks_like_build_failure, output_looks_like_test_run, research_prose_cap,
344    };
345    use serial_test::serial;
346
347    /// #980/#985: search tool results must compress without corrupting source.
348    #[test]
349    fn search_tool_result_is_compressed_without_corrupting_source() {
350        let raw = (0..60)
351            .map(|i| {
352                format!(
353                    "src/h.go:{i}:func handler{i}(ctx context.Context) (api.Result, error) \
354                     {{ return doWork(ctx) }}"
355                )
356            })
357            .collect::<Vec<_>>()
358            .join("\n");
359        let out = compress_tool_result(&raw, Some("search_files"));
360
361        assert!(
362            out.len() < raw.len(),
363            "search results must still compress ({} -> {})",
364            raw.len(),
365            out.len()
366        );
367        for keyword in ["context.Context", "(api.Result, error)", "return"] {
368            assert!(
369                out.contains(keyword),
370                "the terse dictionary rewrote `{keyword}` out of a search result:\n{out}"
371            );
372        }
373    }
374
375    #[test]
376    fn short_content_unchanged() {
377        let short = "hello world";
378        assert_eq!(compress_tool_result(short, None), short);
379    }
380
381    #[test]
382    fn empty_content_unchanged() {
383        assert_eq!(compress_tool_result("", None), "");
384        assert_eq!(compress_tool_result("   ", None), "   ");
385    }
386
387    #[test]
388    fn command_hint_extraction() {
389        assert_eq!(
390            extract_command_hint("$ cargo build\nCompiling foo"),
391            Some("cargo build".to_string())
392        );
393        assert_eq!(extract_command_hint("no prefix here"), None);
394    }
395
396    #[test]
397    fn tool_name_inference() {
398        assert_eq!(infer_command("some text", Some("bash_execute")), "shell");
399        assert_eq!(infer_command("some text", Some("search_files")), "grep");
400        assert_eq!(infer_command("some text", Some("unknown_tool")), "");
401    }
402
403    #[test]
404    fn lean_ctx_tool_results_pass_through_verbatim() {
405        // A ctx_shell result the tool already produced (raw=true, or its own
406        // compression). The proxy must NOT re-compress / re-truncate it — that
407        // was the #479 defect where `raw=true` was silently undone on the wire.
408        let raw = (1..=120)
409            .map(|i| format!("Line {i:04}: the quick brown fox jumps over the lazy dog"))
410            .collect::<Vec<_>>()
411            .join("\n");
412        assert!(raw.len() > 200);
413        // Bare names AND the namespaced forms real MCP clients emit (Claude Code
414        // `mcp__lean-ctx__ctx_shell`, colon-style `lean-ctx:ctx_read`).
415        for tool in [
416            "ctx_shell",
417            "ctx_read",
418            "ctx_search",
419            "ctx_grep",
420            "mcp__lean-ctx__ctx_shell",
421            "lean-ctx:ctx_read",
422        ] {
423            assert_eq!(
424                compress_tool_result(&raw, Some(tool)),
425                raw,
426                "{tool} output must pass through the proxy verbatim"
427            );
428        }
429        // A foreign tool with identical output is still compressed: the proxy
430        // keeps adding value for non-lean-ctx tools.
431        assert_ne!(
432            compress_tool_result(&raw, Some("bash")),
433            raw,
434            "foreign-tool output should still be compressed by the proxy"
435        );
436    }
437
438    #[test]
439    fn cited_research_output_is_preserved_verbatim() {
440        let cited = format!(
441            "Rust is a language.\n\n---\nSource: Rust — https://x.com/a\n\
442             Site: x.com · Retrieved: 2026-06-06T00:00:00Z\n{}",
443            "Extra body line that would otherwise be touched. ".repeat(20)
444        );
445        assert_eq!(compress_tool_result(&cited, Some("ctx_url_read")), cited);
446    }
447
448    #[test]
449    fn prose_is_squeezed_and_deduped() {
450        let para = "Rust is a multi-paradigm systems programming language that \
451                    emphasizes performance, type safety, and fearless concurrency, \
452                    achieving memory safety without a garbage collector at runtime.";
453        // Repeated paragraph (well over the 600-char prose floor) → dedup keeps one.
454        let input = format!("{}\n", [para; 8].join("\n\n"));
455        assert!(input.len() > 600);
456        let out = compress_tool_result(&input, Some("web_fetch"));
457        assert_eq!(out.matches("fearless concurrency").count(), 1);
458        assert!(out.contains("performance, type safety"));
459    }
460
461    #[test]
462    #[serial]
463    fn research_prose_cap_env_overrides_default() {
464        let _lock = crate::core::data_dir::test_env_lock();
465        crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, "1234");
466        assert_eq!(research_prose_cap(), 1234);
467        crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
468    }
469
470    #[test]
471    #[serial]
472    fn research_prose_cap_env_invalid_falls_back() {
473        let _lock = crate::core::data_dir::test_env_lock();
474        for value in ["", "not_a_number", "0"] {
475            crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, value);
476            assert_eq!(research_prose_cap(), RESEARCH_PROSE_CAP);
477        }
478        crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
479    }
480
481    #[test]
482    fn code_output_is_not_treated_as_prose() {
483        let code = "fn main() {\n    let x = vec![1, 2, 3];\n    \
484                    for i in &x { println!(\"{}\", i); }\n}\n"
485            .repeat(20);
486        assert!(!looks_like_prose(&code));
487    }
488
489    #[test]
490    fn shell_log_is_not_treated_as_prose() {
491        let log = "$ cargo build\n   Compiling foo v0.1.0\n    Finished dev\n".repeat(20);
492        assert!(!looks_like_prose(&log));
493    }
494
495    #[test]
496    fn foreign_shell_build_failure_preserved_verbatim() {
497        // A forge/pi-style shell tool: the name says "shell" and the output has
498        // no `$ cmd` hint, so the engine's command-gated guards cannot fire. The
499        // compiler error must still reach the model intact for a bug-fix task.
500        let mut log = String::from("gcc -O2 -c src/versioncmp.c -o versioncmp.o\n");
501        log.push_str("src/versioncmp.c: In function 'version_cmp':\n");
502        log.push_str(
503            "src/versioncmp.c:142:17: error: invalid operands to binary < (have 'char *' and 'int')\n",
504        );
505        for i in 0..40 {
506            log.push_str(&format!("  note: expansion context line {i}\n"));
507        }
508        log.push_str("make: *** [Makefile:23: versioncmp.o] Error 1\n");
509
510        let out = compress_tool_result(&log, Some("shell"));
511        assert!(
512            out.contains("versioncmp.c:142:17: error:"),
513            "compiler error must survive the proxy"
514        );
515        assert!(
516            out.contains("make: ***"),
517            "make failure summary must survive"
518        );
519    }
520
521    #[test]
522    fn foreign_shell_test_failure_preserved_verbatim() {
523        let mut log = String::from("running 3 tests\n");
524        log.push_str("test version::tests::sorts_numeric ... FAILED\n");
525        for i in 0..40 {
526            log.push_str(&format!("note line {i} with some filler content here\n"));
527        }
528        log.push_str("test result: FAILED. 2 passed; 1 failed; 0 ignored\n");
529
530        let out = compress_tool_result(&log, Some("bash"));
531        assert!(
532            out.contains("test result: FAILED"),
533            "test summary must survive the proxy"
534        );
535        assert!(out.contains("sorts_numeric ... FAILED"));
536    }
537
538    #[test]
539    fn plain_shell_log_not_forced_verbatim() {
540        let log = "Listening on port 8080\nRequest received from 10.0.0.2\n".repeat(20);
541        assert!(!output_looks_like_test_run(&log));
542        assert!(!output_looks_like_build_failure(&log));
543    }
544
545    fn big_compressible_log() -> String {
546        (1..=400)
547            .map(|i| format!("[info] processed item {i:04} ok"))
548            .collect::<Vec<_>>()
549            .join("\n")
550    }
551
552    #[test]
553    fn live_compression_is_recoverable_via_ccr_handle() {
554        let _lock = crate::core::data_dir::test_env_lock();
555        let log = big_compressible_log();
556        let out = compress_tool_result(&log, Some("bash"));
557        assert!(
558            out.len() < log.len(),
559            "a large foreign log must be compressed"
560        );
561
562        // The compressed result carries the content-addressed handle, and the
563        // handle points at the *verbatim* original — live compression is now
564        // non-lossy (#482), recoverable with a plain native file read.
565        let handle = ccr::persist(&log).expect("same content -> same handle");
566        assert!(out.contains(&handle), "CCR handle must be embedded: {out}");
567        let recovered = std::fs::read_to_string(&handle).expect("tee file readable");
568        assert!(
569            recovered.contains("processed item 0007 ok")
570                && recovered.contains("processed item 0400 ok"),
571            "verbatim original must be fully recoverable"
572        );
573    }
574
575    #[test]
576    fn live_compression_output_is_byte_stable_across_turns() {
577        let _lock = crate::core::data_dir::test_env_lock();
578        let log = big_compressible_log();
579        let a = compress_tool_result(&log, Some("bash"));
580        let b = compress_tool_result(&log, Some("bash"));
581        assert_eq!(
582            a, b,
583            "the CCR handle is content-addressed, so the rewritten result must be \
584             byte-identical across turns (provider cache prefix stays valid, #448)"
585        );
586    }
587
588    /// Contract test for #702, pinned to LiteLLM's marker regex: the headroom
589    /// guardrail scans compressed text with `hash=([a-f0-9]{24})`
590    /// (BerriAI/litellm#31681). If our gateway stub ever drifts from that
591    /// shape, the CCR agentic loop silently stops firing — this test fails
592    /// first.
593    #[test]
594    fn gateway_stub_matches_litellm_marker_regex_and_retrieves() {
595        let _lock = crate::core::data_dir::test_env_lock();
596        let log = big_compressible_log();
597        let out = compress_tool_result_gateway(&log, Some("bash"));
598        assert!(out.len() < log.len(), "gateway funnel must still compress");
599
600        // Exactly the pattern LiteLLM compiles (`_HASH_PATTERN`).
601        let litellm_regex = regex::Regex::new(r"hash=([a-f0-9]{24})").unwrap();
602        let captured = litellm_regex
603            .captures(&out)
604            .unwrap_or_else(|| panic!("gateway stub must carry a hash= marker: {out}"))
605            .get(1)
606            .unwrap()
607            .as_str();
608        assert_eq!(captured, ccr::litellm_hash(&log));
609
610        // The captured hash resolves through the /v1/retrieve/{hash} resolver
611        // to the verbatim original — the full guardrail round-trip.
612        let recovered = ccr::retrieve_litellm(captured).expect("captured hash must resolve");
613        assert!(
614            recovered.contains("processed item 0007 ok")
615                && recovered.contains("processed item 0400 ok"),
616            "retrieve must return the verbatim original"
617        );
618
619        // Byte-stable across calls (#498): the marker is content-addressed.
620        assert_eq!(out, compress_tool_result_gateway(&log, Some("bash")));
621
622        // The functional CCR line must survive the savings-footer strip that
623        // /v1/compress applies to every payload.
624        assert_eq!(
625            crate::core::protocol::strip_trailing_savings_footer(&out),
626            out
627        );
628    }
629
630    #[test]
631    fn gateway_stub_absent_for_passthrough_and_ctx_output() {
632        let _lock = crate::core::data_dir::test_env_lock();
633        // Below the compress floor: no marker.
634        assert!(!compress_tool_result_gateway("short output", Some("bash")).contains("hash="));
635        // lean-ctx tool output passes through verbatim — a gateway must never
636        // see a retrieval marker for content that was not rewritten.
637        let raw = (1..=120)
638            .map(|i| format!("Line {i:04}: lorem ipsum dolor sit amet consectetur"))
639            .collect::<Vec<_>>()
640            .join("\n");
641        let out = compress_tool_result_gateway(&raw, Some("ctx_shell"));
642        assert_eq!(out, raw);
643    }
644
645    #[test]
646    fn small_or_passthrough_output_gets_no_ccr_handle() {
647        let _lock = crate::core::data_dir::test_env_lock();
648        // Below the 200-char compress floor: passes through, no handle.
649        let tiny = "ok\n".repeat(10);
650        assert!(!compress_tool_result(&tiny, Some("bash")).contains("full original at"));
651        // lean-ctx tool output passes through verbatim (no handle either).
652        let raw = (1..=120)
653            .map(|i| format!("Line {i:04}: lorem ipsum dolor sit amet consectetur"))
654            .collect::<Vec<_>>()
655            .join("\n");
656        let out = compress_tool_result(&raw, Some("ctx_shell"));
657        assert_eq!(out, raw, "lean-ctx tool result must stay verbatim (no CCR)");
658    }
659}