Skip to main content

lean_ctx/proxy/
anthropic.rs

1use axum::{
2    body::Body,
3    extract::State,
4    http::{Request, StatusCode},
5    response::Response,
6};
7use serde_json::Value;
8
9use super::ProxyState;
10use super::forward;
11use super::tool_kind::{self, ToolResultKind};
12use super::{cache_safety, prose};
13use crate::core::config::{HistoryMode, ProseRole};
14
15pub async fn handler(
16    State(state): State<ProxyState>,
17    req: Request<Body>,
18) -> Result<Response, StatusCode> {
19    let upstream = state.anthropic_upstream();
20    forward::forward_request(
21        State(state),
22        req,
23        &upstream,
24        "/v1/messages",
25        compress_request_body,
26        "Anthropic",
27        &[],
28    )
29    .await
30}
31
32pub(super) fn compress_request_body(
33    parsed: Value,
34    original_size: usize,
35) -> (Vec<u8>, usize, usize) {
36    let mut doc = parsed;
37    let mut modified = false;
38
39    // Opt-in per-role prose aggressiveness (#710). Both default to `None`, in
40    // which case nothing below fires and the body is byte-for-byte unchanged.
41    let cfg = crate::core::config::Config::load();
42    let system_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::System);
43    let user_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::User);
44    let live_compress = cfg.proxy.live_compresses();
45    let mode = cfg.proxy.resolved_history_mode();
46    // #939: active prompt-cache breakpoint injection (opt-in, Anthropic-only).
47    // Resolved up front so the meter-only short-circuit below does not skip the
48    // one mutation this mode performs — its whole point is to add a cache anchor
49    // to an otherwise byte-passthrough request.
50    let inject_breakpoint = cfg.proxy.cache_breakpoint_enabled();
51    // #940: cache-aligner volatile-field telemetry (default-on, measurement-only).
52    // Also resolved up front so a meter-only proxy still reaches the scan slot —
53    // it never mutates the body, it only records how much cache the system prompt
54    // leaks, so it ships on for every proxy (#986 premium defaults).
55    let align_volatile = cfg.proxy.cache_aligner_enabled();
56    // #974: active cache-aligner relocate (opt-in, Anthropic-only). Resolved up
57    // front like the telemetry above so a meter-only proxy still reaches the
58    // relocate slot — this is the one mutation that moves volatile fields out of
59    // the cacheable prefix.
60    let relocate_volatile = cfg.proxy.cache_align_relocate_enabled();
61    // #986: cache-economics (default-on). Resolved up front so the meter-only
62    // short-circuit below still reaches the miss-attribution slot — that
63    // telemetry only reads the cacheable prefix, it never mutates the body, and
64    // the paired net-cost gate only makes the cold-prefix repack more
65    // conservative, so both halves ship on for every proxy (#986 premium
66    // defaults).
67    let cache_economics = cfg.proxy.cache_policy_enabled();
68    // #895 Track B: output-savings holdout arm, from the pristine body (before any
69    // mutation below) so it matches the arm the response meter records. Control
70    // conversations skip output-shaping (effort + verbosity steer) but are still
71    // metered. Default holdout=0 → always Treatment (no behaviour change).
72    let arm = super::holdout::assign(
73        &super::holdout::anthropic_key(&doc),
74        cfg.proxy.output_holdout_fraction(),
75    );
76    // #493: in-band CCR expansion (opt-in). Splice any <lc_expand:HASH> the model
77    // echoed back into the verbatim original from the local tee store. A strict
78    // no-op when no marker is present (byte-identical body → cache-safe). Runs
79    // before the meter-only short-circuit so an explicit expand request is
80    // honored even when the proxy is otherwise byte-passthrough.
81    if cfg.proxy.ccr_inband_enabled() {
82        modified |= super::ccr::splice_inband_in_place(&mut doc);
83    }
84    // #834: cache-safe cross-provider effort control. Default off → no-op. The
85    // value is a constant, so it never perturbs the prompt-cache prefix; it only
86    // dials an *existing* adaptive thinking request (never enables thinking the
87    // client didn't ask for).
88    if arm == super::holdout::Arm::Treatment {
89        if let Some(effort) = cfg.proxy.resolved_effort() {
90            modified |= super::effort::apply_anthropic(&mut doc, effort);
91        }
92        // #895: cache-safe wire verbosity steer (constant suffix after the last
93        // cache_control breakpoint). Control arm skips it so the holdout measures
94        // its effect.
95        if cfg.proxy.verbosity_steer_enabled() {
96            modified |= super::verbosity::apply_anthropic(&mut doc);
97        }
98    }
99    // Meter-only (#481): live compression off, no history pruning, no prose
100    // rewriting → forward + usage metering still run, but the body is left
101    // unchanged so the provider prompt-cache prefix stays byte-stable. A pending
102    // in-band splice (`modified`) opts out: the body did change this turn.
103    if !live_compress
104        && mode == HistoryMode::Off
105        && system_aggr.is_none()
106        && user_aggr.is_none()
107        && !modified
108        && !inject_breakpoint
109        && !align_volatile
110        && !relocate_volatile
111        && !cache_economics
112    {
113        let out = serde_json::to_vec(&doc).unwrap_or_default();
114        return (out, original_size, original_size);
115    }
116    let mut prose_segments: u64 = 0;
117
118    // Length of the client's provider-cached message prefix. Needed both for
119    // cache-safe pruning below and to gate top-level system prose: if any
120    // message is client-cached, `system` (which precedes every message) is part
121    // of that cached prefix and must not be rewritten.
122    let cached = doc
123        .get("messages")
124        .and_then(|m| m.as_array())
125        .map_or(0, |m| super::history_prune::cached_prefix_len(m));
126
127    // #480: opt-in big-gap cold-prefix repack. When enabled AND the proxy can
128    // confidently predict (from idle time vs the provider cache TTL) that the
129    // client-cached prefix is already cold, override the normal "never touch the
130    // cached prefix" rule for THIS request and prune/compress the prefix too,
131    // re-seeding a leaner cache. Default-off; never fires without a measured idle
132    // gap past TTL × margin, so warm caches stay byte-stable (#448).
133    // #986: cache-economics miss attribution (opt-in, measurement-only). Classify
134    // why this turn hits or misses the provider prompt-cache (TTL lapse vs prefix
135    // change) and bump the `/status` gauges. Reads the cacheable prefix only — the
136    // body is never touched — so it is strictly cache-safe.
137    if cache_economics && let Some(m) = doc.get("messages").and_then(|m| m.as_array()) {
138        super::cache_attribution::record_request(m, cached);
139    }
140    // #480 repack decision, with the #986 net-cost gate folded in: when
141    // cache-economics is on, also require the prefix to be large enough to cache
142    // (`worth_repacking`). The gate is an extra AND-condition, so it can only make
143    // repacking *more* conservative; default-off proxies keep the prior value.
144    let repack = cfg.proxy.repacks_cold_prefix()
145        && doc
146            .get("messages")
147            .and_then(|m| m.as_array())
148            .is_some_and(|m| {
149                super::cold_prefix::repack_decision(m, cached)
150                    && (!cache_economics
151                        || super::cache_policy::worth_repacking(doc.get("system"), m, cached))
152            });
153    // The prefix length the rewrites below must protect: the full cached prefix
154    // normally, or 0 when we are intentionally repacking the cold prefix.
155    let protect = if repack { 0 } else { cached };
156
157    // System prose: only when nothing is client-cached and the `system` field
158    // carries no `cache_control` of its own — otherwise it anchors the cache.
159    // A cold-prefix repack (`protect == 0` with `repack`) deliberately rewrites
160    // it to re-seed a leaner cache.
161    if let Some(a) = system_aggr
162        && protect == 0
163        && let Some(system) = doc.get_mut("system")
164        && (repack || !prose::value_has_cache_control(system))
165    {
166        let n = prose::compress_system_value(system, a);
167        if n > 0 {
168            prose_segments += u64::from(n);
169            modified = true;
170        }
171    }
172
173    if let Some(messages) = doc.get_mut("messages").and_then(|m| m.as_array_mut()) {
174        // Resolve tool-call id → tool name so file/source reads can be protected
175        // from lossy compression that would force the model to re-read mid-task.
176        let tool_names = tool_kind::anthropic_tool_names(messages);
177
178        // Prune at a frozen, cache-aware boundary by default: Anthropic's
179        // prompt cache matches exact prefixes, so the boundary must not move
180        // every turn (see `history_prune::prune_boundary`). `mode` resolved above.
181        let boundary = super::history_prune::prune_boundary(mode, messages.len());
182        // Never rewrite content the client has marked with `cache_control`:
183        // pruning inside the already-cached prefix invalidates Anthropic's
184        // prompt cache from the first changed message (#448). Pruning therefore
185        // starts after the last breakpoint; with no breakpoint this is 0, i.e.
186        // the previous behaviour.
187        modified |=
188            super::history_prune::prune_history_range(messages, protect, boundary, &tool_names);
189
190        for msg in messages.iter_mut() {
191            let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
192            if role != "user" {
193                continue;
194            }
195
196            if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
197                for block in content.iter_mut() {
198                    if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
199                        continue;
200                    }
201
202                    let name = block
203                        .get("tool_use_id")
204                        .and_then(|v| v.as_str())
205                        .and_then(|id| tool_names.get(id))
206                        .map(String::as_str);
207                    let kind = name.map_or(ToolResultKind::Other, tool_kind::classify_tool_name);
208
209                    // #481: skip live compression when globally off or when the
210                    // originating tool is on the exclusion list (Serena default).
211                    let excluded =
212                        name.is_some_and(|n| cfg.proxy.is_tool_live_compress_excluded(n));
213                    if live_compress
214                        && !excluded
215                        && let Some(inner_content) = block.get_mut("content")
216                    {
217                        modified |= compress_content_field(inner_content, name, kind);
218                    }
219                }
220            }
221        }
222
223        // Frozen-region user prose: free-text `text` blocks of user turns in
224        // `[cached, boundary)`. Cache-safe by construction — the cached prefix
225        // and the live tail (`>= boundary`) are both left intact, and the
226        // rewrite is content-deterministic so the prefix stays byte-stable.
227        if let Some(a) = user_aggr {
228            let end = boundary.min(messages.len());
229            let start = protect.min(end);
230            for msg in &mut messages[start..end] {
231                if msg.get("role").and_then(|r| r.as_str()) == Some("user")
232                    && let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut())
233                {
234                    prose_segments += u64::from(prose::compress_text_blocks(content, a));
235                }
236            }
237        }
238    }
239
240    if prose_segments > 0 {
241        modified = true;
242    }
243    // #940: cache-aligner telemetry. On an unanchored system prompt (the prefix a
244    // provider would cache), count the volatile fields that would bust that cache
245    // turn-to-turn. Pure measurement — runs before any breakpoint injection and
246    // never mutates the body — so it is strictly cache-safe. Skipped once the
247    // client has anchored the prefix itself.
248    if align_volatile
249        && cached == 0
250        && let Some(system) = doc.get("system")
251        && !prose::value_has_cache_control(system)
252        && let Some(text) = super::cache_aligner::system_text(system)
253    {
254        let scan = super::cache_aligner::scan_volatile(&text);
255        cache_safety::record_volatile_system(scan.fields as u64);
256    }
257    // #974: active cache-aligner relocate (Anthropic-only). After the telemetry
258    // above measured the leak on the pristine prompt, move the volatile values out
259    // of the cacheable prefix into an uncached tail block so the prefix finally
260    // caches. Gated exactly like the breakpoint below — Treatment arm, and only
261    // when the client anchored nothing of its own. The rewrite adds the
262    // `cache_control` itself, so a following #939 injection sees an anchored prefix
263    // and stays a no-op: the two compose to exactly one breakpoint on the stable
264    // block, with the volatile tail left uncached.
265    if relocate_volatile
266        && arm == super::holdout::Arm::Treatment
267        && cached == 0
268        && doc
269            .get("system")
270            .is_some_and(|s| !prose::value_has_cache_control(s))
271    {
272        let relocated = super::cache_aligner::apply_anthropic_relocate(&mut doc);
273        if relocated > 0 {
274            modified = true;
275            cache_safety::record_volatile_relocated(relocated as u64);
276        }
277    }
278    // #939: active prompt-cache breakpoint injection (Anthropic-only). When the
279    // client anchored no prefix of its own — no message `cache_control` (`cached
280    // == 0`) and no breakpoint already on `system` — add one ephemeral breakpoint
281    // to `system` so the large, stable system prompt bills later turns at the
282    // cached rate (the win a raw API client leaves on the table). Runs after every
283    // frozen-region rewrite so the marker anchors the final system bytes and the
284    // prefix it creates stays byte-stable across turns (#498). Counted on its own
285    // gauge — a pure win, never against the cache-safe ratio.
286    if inject_breakpoint
287        && cached == 0
288        && doc
289            .get("system")
290            .is_some_and(|s| !prose::value_has_cache_control(s))
291        && super::cache_breakpoint::inject_anthropic_system(&mut doc)
292    {
293        modified = true;
294        cache_safety::record_breakpoint_injected();
295    }
296    // A deliberate cold-prefix repack (#480) is the one sanctioned exception to
297    // the frozen-window rule; count it on its own gauge so it never dilutes the
298    // cache-safe ratio (which exists to catch *accidental* #448 regressions).
299    // Every other rewrite lands strictly inside the cache-safe frozen window.
300    if repack {
301        cache_safety::record_cold_repack();
302    }
303    cache_safety::record(prose_segments, true);
304
305    let out = serde_json::to_vec(&doc).unwrap_or_default();
306    let compressed_size = if modified { out.len() } else { original_size };
307    (out, original_size, compressed_size)
308}
309
310/// Compresses a tool_result `content` field unless it is a protected file/source
311/// read, which must reach the model intact (it is what gets edited).
312fn compress_content_field(
313    content: &mut Value,
314    tool_name: Option<&str>,
315    kind: ToolResultKind,
316) -> bool {
317    match content {
318        Value::String(s) => super::tool_output::compress_text(s, tool_name, kind),
319        Value::Array(arr) => {
320            let mut modified = false;
321            for item in arr.iter_mut() {
322                if item.get("type").and_then(|t| t.as_str()) == Some("text")
323                    && let Some(Value::String(text)) = item.get_mut("text")
324                {
325                    modified |= super::tool_output::compress_text(text, tool_name, kind);
326                }
327            }
328            modified
329        }
330        _ => false,
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::super::compress::compress_tool_result;
337    use super::*;
338
339    fn source_file_body() -> Vec<u8> {
340        let code = (0..60)
341            .map(|i| format!("    let binding_{i} = compute_value_{i}(context, options);"))
342            .collect::<Vec<_>>()
343            .join("\n");
344        let body = serde_json::json!({
345            "model": "claude-opus-4-8",
346            "messages": [
347                {
348                    "role": "assistant",
349                    "content": [{"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {"file_path": "src/app.rs"}}]
350                },
351                {
352                    "role": "user",
353                    "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": code}]
354                }
355            ]
356        });
357        serde_json::to_vec(&body).unwrap()
358    }
359
360    #[test]
361    fn read_tool_result_is_never_truncated() {
362        let bytes = source_file_body();
363        let body: Value = serde_json::from_slice(&bytes).unwrap();
364        let (out, _orig, _comp) = compress_request_body(body, bytes.len());
365        let parsed: Value = serde_json::from_slice(&out).unwrap();
366        let content = parsed["messages"][1]["content"][0]["content"]
367            .as_str()
368            .unwrap();
369        assert!(
370            content.contains("binding_59"),
371            "the full source body must survive — refactors need it intact"
372        );
373        assert!(!content.contains("lines omitted"));
374    }
375
376    fn forge_log_body(tool_name: &str) -> Value {
377        // Generic, highly-repetitive log with no `$ cmd` hint, so routing falls
378        // back to the tool name (exercising the foreign-tool classification)
379        // and the generic compressor (not a command-specific pattern).
380        let mut log = String::new();
381        for i in 0..90 {
382            log.push_str(&format!(
383                "INFO  processing item {i}: ok, latency={i}ms, queue depth normal, retries 0\n"
384            ));
385        }
386        serde_json::json!({
387            "messages": [
388                {"role": "assistant", "content": [{"type": "tool_use", "id": "f1", "name": tool_name, "input": {}}]},
389                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "f1", "content": log}]}
390            ]
391        })
392    }
393
394    #[test]
395    fn forge_shell_tool_result_compresses() {
396        // A vendor-prefixed foreign shell tool reaches the proxy; its log output
397        // must still be compressed (rtk/ctx_* never see another server's tools).
398        let body = forge_log_body("forge_shell");
399        let bytes = serde_json::to_vec(&body).unwrap();
400        let (_out, orig, comp) = compress_request_body(body, bytes.len());
401        assert!(comp < orig, "foreign shell output must be compressed");
402    }
403
404    #[test]
405    fn foreign_read_tool_protects_source() {
406        // `forge_read` is classified FileRead via the segment fallback, so the
407        // source body must reach the model intact (it is what gets edited).
408        let code = (0..60)
409            .map(|i| format!("    let binding_{i} = compute_value_{i}(context, options);"))
410            .collect::<Vec<_>>()
411            .join("\n");
412        let body = serde_json::json!({
413            "messages": [
414                {"role": "assistant", "content": [{"type": "tool_use", "id": "r1", "name": "forge_read", "input": {"path": "src/app.rs"}}]},
415                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "r1", "content": code}]}
416            ]
417        });
418        let bytes = serde_json::to_vec(&body).unwrap();
419        let (out, _orig, _comp) = compress_request_body(body, bytes.len());
420        let parsed: Value = serde_json::from_slice(&out).unwrap();
421        let content = parsed["messages"][1]["content"][0]["content"]
422            .as_str()
423            .unwrap();
424        assert!(
425            content.contains("binding_59"),
426            "source body must survive intact"
427        );
428    }
429
430    #[test]
431    fn compress_request_body_is_deterministic() {
432        // tee path depends on the data dir; serialize env access so a parallel
433        // test never swaps LEAN_CTX_DATA_DIR between the two compressions.
434        let _lock = crate::core::data_dir::test_env_lock();
435        // #498: the proxy rewrite must be a pure function of the body so the
436        // provider prompt-cache prefix stays byte-identical across turns.
437        let bytes = serde_json::to_vec(&forge_log_body("Bash")).unwrap();
438        let a = compress_request_body(serde_json::from_slice(&bytes).unwrap(), bytes.len()).0;
439        let b = compress_request_body(serde_json::from_slice(&bytes).unwrap(), bytes.len()).0;
440        assert_eq!(a, b, "identical input must yield byte-identical output");
441    }
442
443    /// A large, highly-compressible foreign log so the live path tees + stubs it.
444    fn big_log() -> String {
445        (0..200)
446            .map(|i| format!("[info] processed item {i:04} ok, latency {i}ms, queue normal"))
447            .collect::<Vec<_>>()
448            .join("\n")
449    }
450
451    #[test]
452    fn inband_ccr_emit_echo_splice_round_trip() {
453        // Full #493 cycle through the real Anthropic request path: a lossy stub
454        // emits an <lc_expand:HASH> marker, the model echoes it, and the proxy
455        // splices the verbatim original back inline on the next request.
456        let _iso = crate::core::data_dir::isolated_data_dir();
457        crate::test_env::remove_var("LEAN_CTX_PROXY_CCR_INBAND");
458        crate::core::config::Config::update_global(|c| {
459            c.proxy.ccr_inband = Some(true);
460        })
461        .unwrap();
462
463        // EMIT: live-compress a foreign tool_result → recovery stub with a marker.
464        let log = big_log();
465        let emit = serde_json::json!({
466            "messages": [
467                {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "bash", "input": {}}]},
468                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": log}]}
469            ]
470        });
471        let bytes = serde_json::to_vec(&emit).unwrap();
472        let (out, _o, _c) = compress_request_body(emit, bytes.len());
473        let emitted: Value = serde_json::from_slice(&out).unwrap();
474        let stub = emitted["messages"][1]["content"][0]["content"]
475            .as_str()
476            .unwrap();
477        assert!(
478            stub.contains("<lc_expand:"),
479            "in-band stub must advertise an echo-able marker: {stub}"
480        );
481        assert!(
482            !stub.contains("/tee/proxy_"),
483            "in-band stub must not leak the unreachable local tee path: {stub}"
484        );
485
486        // The marker the model would copy into its next turn.
487        let start = stub.find("<lc_expand:").unwrap();
488        let end = stub[start..].find('>').unwrap() + start + 1;
489        let marker = &stub[start..end];
490
491        // ECHO + SPLICE: the model echoes the marker; the proxy splices the
492        // verbatim original (recovered from the local tee store) back inline.
493        let echo = serde_json::json!({
494            "messages": [
495                {"role": "user", "content": [{"type": "text", "text": "look again"}]},
496                {"role": "assistant", "content": format!("revisiting that output: {marker}")}
497            ]
498        });
499        let bytes = serde_json::to_vec(&echo).unwrap();
500        let (out, _o, _c) = compress_request_body(echo, bytes.len());
501        let spliced: Value = serde_json::from_slice(&out).unwrap();
502        let assistant = spliced["messages"][1]["content"].as_str().unwrap();
503        assert!(
504            assistant.contains("processed item 0007 ok")
505                && assistant.contains("processed item 0199 ok"),
506            "the verbatim original must be spliced back in full: {assistant}"
507        );
508        assert!(
509            !assistant.contains("<lc_expand:"),
510            "the marker must be consumed by the splice"
511        );
512    }
513
514    #[test]
515    fn inband_marker_less_turn_is_byte_identical_on_or_off() {
516        // Cache-safety (#493): enabling in-band must be a strict no-op on a turn
517        // with no marker — same bytes on the wire, so the provider cache prefix is
518        // never perturbed unless the model actually asked to expand. Uses a body
519        // with nothing to prune/compress, isolating the splice from stub emission.
520        let _iso = crate::core::data_dir::isolated_data_dir();
521        crate::test_env::remove_var("LEAN_CTX_PROXY_CCR_INBAND");
522        let body = serde_json::json!({
523            "messages": [
524                {"role": "user", "content": [{"type": "text", "text": "hello there"}]},
525                {"role": "assistant", "content": "hi — how can I help?"}
526            ]
527        });
528        let bytes = serde_json::to_vec(&body).unwrap();
529
530        crate::core::config::Config::update_global(|c| c.proxy.ccr_inband = Some(false)).unwrap();
531        let off = compress_request_body(body.clone(), bytes.len()).0;
532        crate::core::config::Config::update_global(|c| c.proxy.ccr_inband = Some(true)).unwrap();
533        let on = compress_request_body(body, bytes.len()).0;
534
535        assert_eq!(
536            off, on,
537            "a marker-less request must be byte-identical whether in-band is on or off"
538        );
539    }
540
541    /// Long, duplicate-rich natural-language prose that compresses cleanly.
542    fn big_prose() -> String {
543        let p = "You are a careful, senior software engineer. You always explain your \
544                 reasoning before making changes, you prefer small reviewable diffs, and \
545                 you never introduce mock data or placeholders into production code. ";
546        [p; 6].join("\n")
547    }
548
549    #[test]
550    fn system_prose_compressed_and_assistant_untouched() {
551        let _iso = crate::core::data_dir::isolated_data_dir();
552        crate::core::config::Config::update_global(|c| {
553            c.proxy.role_aggressiveness.system = Some(0.6);
554            c.proxy.role_aggressiveness.user = Some(0.6);
555        })
556        .unwrap();
557
558        let prose = big_prose();
559        let assistant_text = big_prose();
560        let body = serde_json::json!({
561            "model": "claude-opus-4-8",
562            "system": prose,
563            "messages": [
564                {"role": "user", "content": [{"type": "text", "text": prose}]},
565                {"role": "assistant", "content": assistant_text},
566            ]
567        });
568        let bytes = serde_json::to_vec(&body).unwrap();
569        let (out, _orig, _comp) = compress_request_body(body, bytes.len());
570        let parsed: Value = serde_json::from_slice(&out).unwrap();
571
572        assert!(
573            parsed["system"].as_str().unwrap().len() < prose.len(),
574            "system prose must be compressed when enabled"
575        );
576        assert_eq!(
577            parsed["messages"][1]["content"].as_str().unwrap(),
578            assistant_text,
579            "assistant turns must pass through verbatim (#710)"
580        );
581    }
582
583    #[test]
584    fn user_prose_compressed_only_in_frozen_region() {
585        let _iso = crate::core::data_dir::isolated_data_dir();
586        crate::core::config::Config::update_global(|c| {
587            c.proxy.role_aggressiveness.user = Some(0.7);
588        })
589        .unwrap();
590
591        let prose = big_prose();
592        // 30 messages → cache-aware boundary = ((30-8)/16)*16 = 16.
593        let mut messages = Vec::new();
594        for i in 0..30 {
595            let role = if i % 2 == 0 { "user" } else { "assistant" };
596            messages.push(serde_json::json!({
597                "role": role,
598                "content": [{"type": "text", "text": prose}]
599            }));
600        }
601        let body = serde_json::json!({ "messages": messages });
602        let bytes = serde_json::to_vec(&body).unwrap();
603        let (out, _o, _c) = compress_request_body(body, bytes.len());
604        let parsed: Value = serde_json::from_slice(&out).unwrap();
605
606        let frozen_user = parsed["messages"][0]["content"][0]["text"]
607            .as_str()
608            .unwrap();
609        assert!(
610            frozen_user.len() < prose.len(),
611            "user prose in the frozen region must be compressed"
612        );
613        assert_eq!(
614            parsed["messages"][1]["content"][0]["text"]
615                .as_str()
616                .unwrap(),
617            prose,
618            "assistant prose is never compressed"
619        );
620        let live_tail_user = parsed["messages"][28]["content"][0]["text"]
621            .as_str()
622            .unwrap();
623        assert_eq!(
624            live_tail_user, prose,
625            "user prose in the live tail (>= boundary) must be preserved for quality"
626        );
627    }
628
629    #[test]
630    fn client_cached_prefix_disables_system_prose() {
631        let _iso = crate::core::data_dir::isolated_data_dir();
632        crate::core::config::Config::update_global(|c| {
633            c.proxy.role_aggressiveness.system = Some(0.9);
634        })
635        .unwrap();
636
637        let prose = big_prose();
638        let body = serde_json::json!({
639            "system": prose,
640            "messages": [
641                {"role": "user", "content": [
642                    {"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}
643                ]},
644                {"role": "assistant", "content": "ok"}
645            ]
646        });
647        let bytes = serde_json::to_vec(&body).unwrap();
648        let (out, _o, _c) = compress_request_body(body, bytes.len());
649        let parsed: Value = serde_json::from_slice(&out).unwrap();
650        assert_eq!(
651            parsed["system"].as_str().unwrap(),
652            prose,
653            "system must stay verbatim when the client caches a message prefix (#448)"
654        );
655    }
656
657    #[test]
658    fn prose_compression_is_deterministic() {
659        let _iso = crate::core::data_dir::isolated_data_dir();
660        crate::core::config::Config::update_global(|c| {
661            c.proxy.role_aggressiveness.system = Some(0.6);
662        })
663        .unwrap();
664        let prose = big_prose();
665        let mk = || serde_json::json!({"system": prose, "messages": [{"role": "user", "content": "hi"}]});
666        let (a, b) = (mk(), mk());
667        let la = serde_json::to_vec(&a).unwrap().len();
668        let lb = serde_json::to_vec(&b).unwrap().len();
669        assert_eq!(
670            compress_request_body(a, la).0,
671            compress_request_body(b, lb).0,
672            "prose compression must be byte-identical for identical input (#498)"
673        );
674    }
675
676    #[test]
677    fn bash_tool_result_still_compresses() {
678        let log = {
679            let mut s = String::from(
680                "$ git status\nOn branch main\nYour branch is up to date with 'origin/main'.\n\nChanges not staged for commit:\n  (use \"git add <file>...\" to update what will be committed)\n",
681            );
682            for i in 0..90 {
683                s.push_str(&format!("\tmodified:   src/module_{i}/file_{i}.rs\n"));
684            }
685            s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
686            s
687        };
688        let body = serde_json::json!({
689            "messages": [
690                {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]},
691                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": log}]}
692            ]
693        });
694        let bytes = serde_json::to_vec(&body).unwrap();
695        let (_out, orig, comp) = compress_request_body(body, bytes.len());
696        assert!(comp < orig, "shell output must still be compressed");
697    }
698
699    #[test]
700    fn json_envelope_tool_result_is_compressed() {
701        let _iso = crate::core::data_dir::isolated_data_dir();
702        let log = long_git_status();
703        let expected = compress_tool_result(&log, Some("Bash"));
704        let envelope = serde_json::to_string(&serde_json::json!({
705            "content": [{"type": "text", "text": log}],
706            "isError": false,
707        }))
708        .unwrap();
709        let body = serde_json::json!({
710            "messages": [
711                {"role": "assistant", "content": [{
712                    "type": "tool_use",
713                    "id": "t1",
714                    "name": "Bash",
715                    "input": {}
716                }]},
717                {"role": "user", "content": [{
718                    "type": "tool_result",
719                    "tool_use_id": "t1",
720                    "content": envelope
721                }]}
722            ]
723        });
724        let bytes = serde_json::to_vec(&body).unwrap();
725        let (out, orig, comp) = compress_request_body(body, bytes.len());
726
727        assert!(comp < orig, "JSON envelope tool result should shrink");
728        let parsed: Value = serde_json::from_slice(&out).unwrap();
729        let content = parsed["messages"][1]["content"][0]["content"]
730            .as_str()
731            .unwrap();
732        let envelope: Value = serde_json::from_str(content).unwrap();
733        assert_eq!(envelope["content"][0]["text"].as_str().unwrap(), expected);
734    }
735
736    fn long_git_status() -> String {
737        let mut s = String::from(
738            "$ git status\nOn branch main\nYour branch is up to date with 'origin/main'.\n\nChanges not staged for commit:\n  (use \"git add <file>...\" to update what will be committed)\n",
739        );
740        for i in 0..80 {
741            s.push_str(&format!("\tmodified:   src/module_{i}/file_{i}.rs\n"));
742        }
743        s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
744        s
745    }
746
747    /// A client-cached message anchors the prefix; `system` precedes it, so the
748    /// cached prefix is `cached > 0` and system prose is normally protected.
749    /// System-prose verbatim-vs-rewritten is therefore a clean binary signal for
750    /// whether the #480 cold-prefix repack fired.
751    ///
752    /// `first_text` must be UNIQUE per test: it is `messages[0]`, which the
753    /// cold-prefix tracker hashes into the conversation key. A shared global
754    /// last-touch store has no test-clear hook (that would race with the unit
755    /// tests), so distinct keys are how parallel tests stay isolated.
756    fn cached_prefix_body(first_text: &str, prose: &str) -> (Vec<Value>, Value) {
757        let messages = vec![
758            serde_json::json!({"role": "user", "content": [
759                {"type": "text", "text": first_text, "cache_control": {"type": "ephemeral"}}
760            ]}),
761            serde_json::json!({"role": "assistant", "content": "ok"}),
762        ];
763        let body = serde_json::json!({ "system": prose, "messages": messages.clone() });
764        (messages, body)
765    }
766
767    #[test]
768    fn cold_prefix_repack_rewrites_protected_system_prose_when_enabled() {
769        let _iso = crate::core::data_dir::isolated_data_dir();
770        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
771        crate::core::config::Config::update_global(|c| {
772            c.proxy.role_aggressiveness.system = Some(0.9);
773            c.proxy.cold_prefix_repack = Some(true);
774        })
775        .unwrap();
776
777        // The prefix must clear the cacheable floor: with premium defaults the
778        // net-cost gate (#986, on by default) skips repacking a sub-1024-token
779        // prefix the provider could never cache. A real cold prefix worth
780        // re-seeding is large, so size the system prose accordingly.
781        let prose = big_prose().repeat(6);
782        let (messages, body) = cached_prefix_body("cold-repack-enabled-session", &prose);
783        // Predict cold: last touched 3h ago, well past the 5m default TTL × margin.
784        super::super::cold_prefix::test_seed_last_touch(&messages, 3 * 60 * 60);
785
786        let bytes = serde_json::to_vec(&body).unwrap();
787        let (out, _o, _c) = compress_request_body(body, bytes.len());
788        let parsed: Value = serde_json::from_slice(&out).unwrap();
789        assert!(
790            parsed["system"].as_str().unwrap().len() < prose.len(),
791            "a predicted-cold prefix must let the proxy repack the otherwise-protected system prose"
792        );
793    }
794
795    #[test]
796    fn cold_prefix_repack_skipped_for_subcacheable_prefix_by_default() {
797        // #986 premium default: cache_policy is on, so the net-cost gate skips a
798        // cold repack of a prefix below the provider's cacheable minimum —
799        // re-seeding it could never produce a cache the provider keeps. Repack is
800        // enabled and the prefix is cold, but it is too small (≈345 tokens) to
801        // cache, so the system prose must stay protected (unchanged).
802        let _iso = crate::core::data_dir::isolated_data_dir();
803        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
804        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_POLICY");
805        crate::core::config::Config::update_global(|c| {
806            c.proxy.role_aggressiveness.system = Some(0.9);
807            c.proxy.cold_prefix_repack = Some(true);
808        })
809        .unwrap();
810
811        let prose = big_prose();
812        let (messages, body) = cached_prefix_body("cold-repack-subcacheable-session", &prose);
813        super::super::cold_prefix::test_seed_last_touch(&messages, 3 * 60 * 60);
814
815        let bytes = serde_json::to_vec(&body).unwrap();
816        let (out, _o, _c) = compress_request_body(body, bytes.len());
817        let parsed: Value = serde_json::from_slice(&out).unwrap();
818        assert_eq!(
819            parsed["system"].as_str().unwrap(),
820            prose,
821            "the net-cost gate must skip repacking a sub-cacheable prefix (premium default)"
822        );
823    }
824
825    #[test]
826    fn cold_prefix_repack_off_by_default_keeps_prefix_protected() {
827        let _iso = crate::core::data_dir::isolated_data_dir();
828        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
829        crate::core::config::Config::update_global(|c| {
830            c.proxy.role_aggressiveness.system = Some(0.9);
831            c.proxy.cold_prefix_repack = Some(false);
832        })
833        .unwrap();
834
835        let prose = big_prose();
836        let (messages, body) = cached_prefix_body("cold-repack-disabled-session", &prose);
837        // Even with a huge idle gap, default-off must never touch the prefix.
838        super::super::cold_prefix::test_seed_last_touch(&messages, 24 * 60 * 60);
839
840        let bytes = serde_json::to_vec(&body).unwrap();
841        let (out, _o, _c) = compress_request_body(body, bytes.len());
842        let parsed: Value = serde_json::from_slice(&out).unwrap();
843        assert_eq!(
844            parsed["system"].as_str().unwrap(),
845            prose,
846            "with repack off the cached prefix stays byte-stable regardless of idle time (#448)"
847        );
848    }
849
850    #[test]
851    fn cold_prefix_repack_protects_warm_prefix_even_when_enabled() {
852        let _iso = crate::core::data_dir::isolated_data_dir();
853        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
854        crate::core::config::Config::update_global(|c| {
855            c.proxy.role_aggressiveness.system = Some(0.9);
856            c.proxy.cold_prefix_repack = Some(true);
857        })
858        .unwrap();
859
860        let prose = big_prose();
861        let (messages, body) = cached_prefix_body("cold-repack-warm-session", &prose);
862        // Warm: touched 1 minute ago → the prediction must keep protecting.
863        super::super::cold_prefix::test_seed_last_touch(&messages, 60);
864
865        let bytes = serde_json::to_vec(&body).unwrap();
866        let (out, _o, _c) = compress_request_body(body, bytes.len());
867        let parsed: Value = serde_json::from_slice(&out).unwrap();
868        assert_eq!(
869            parsed["system"].as_str().unwrap(),
870            prose,
871            "a warm prefix must stay protected even with repack enabled — only LARGE gaps trigger"
872        );
873    }
874
875    #[test]
876    fn cache_policy_attribution_is_measurement_only() {
877        // #986: enabling cache-economics records miss-attribution telemetry but
878        // must never change the bytes on the wire — the same request compressed
879        // with the policy off vs on is byte-identical (strictly cache-safe).
880        let _iso = crate::core::data_dir::isolated_data_dir();
881        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_POLICY");
882        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
883
884        let prose = big_prose();
885        let (_messages, body) = cached_prefix_body("cache-policy-measurement-session", &prose);
886        let bytes = serde_json::to_vec(&body).unwrap();
887
888        crate::core::config::Config::update_global(|c| {
889            c.proxy.cache_policy = Some(false);
890        })
891        .unwrap();
892        let (off, _o, _c) = compress_request_body(body.clone(), bytes.len());
893
894        crate::core::config::Config::update_global(|c| {
895            c.proxy.cache_policy = Some(true);
896        })
897        .unwrap();
898        let (on, _o, _c) = compress_request_body(body, bytes.len());
899
900        assert_eq!(
901            off, on,
902            "miss attribution is measurement-only: the wire bytes must not change"
903        );
904    }
905
906    #[test]
907    fn effort_control_dials_adaptive_thinking_only() {
908        // #834 end-to-end: fill output_config.effort when the client already
909        // asked for adaptive thinking, but never enable thinking otherwise.
910        let _iso = crate::core::data_dir::isolated_data_dir();
911        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
912        crate::core::config::Config::update_global(|c| {
913            c.proxy.effort = Some("medium".into());
914        })
915        .unwrap();
916
917        let adaptive = serde_json::json!({
918            "model": "claude-opus-4-8",
919            "thinking": {"type": "adaptive"},
920            "messages": [{"role": "user", "content": "hi"}]
921        });
922        let bytes = serde_json::to_vec(&adaptive).unwrap();
923        let (out, _o, _c) = compress_request_body(adaptive, bytes.len());
924        assert_eq!(
925            serde_json::from_slice::<Value>(&out).unwrap()["output_config"]["effort"],
926            "medium"
927        );
928
929        // No thinking field → the proxy must not add output_config (no surprise
930        // reasoning cost, no 400 risk).
931        let plain = serde_json::json!({
932            "model": "claude-opus-4-8",
933            "messages": [{"role": "user", "content": "hi"}]
934        });
935        let bytes = serde_json::to_vec(&plain).unwrap();
936        let (out, _o, _c) = compress_request_body(plain, bytes.len());
937        assert!(
938            serde_json::from_slice::<Value>(&out)
939                .unwrap()
940                .get("output_config")
941                .is_none()
942        );
943    }
944
945    #[test]
946    fn verbosity_steer_applies_to_treatment_skips_control() {
947        // #895: the holdout control arm must be byte-unchanged (so its output is
948        // the measurement baseline); the treatment arm gets the constant steer.
949        let _iso = crate::core::data_dir::isolated_data_dir();
950        crate::test_env::remove_var("LEAN_CTX_PROXY_VERBOSITY_STEER");
951        crate::test_env::remove_var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT");
952        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
953
954        let req = serde_json::json!({
955            "model": "claude-opus-4-8",
956            "messages": [{"role": "user", "content": "Summarize the design."}]
957        });
958        let bytes = serde_json::to_vec(&req).unwrap();
959
960        // Steer on, holdout = 0 → everyone Treatment → steered.
961        crate::core::config::Config::update_global(|c| {
962            c.proxy.verbosity_steer = Some(true);
963            c.proxy.output_holdout = Some(0.0);
964        })
965        .unwrap();
966        let (out, _o, _c) = compress_request_body(req.clone(), bytes.len());
967        let v: Value = serde_json::from_slice(&out).unwrap();
968        assert!(
969            v["messages"][0]["content"]
970                .as_str()
971                .unwrap()
972                .contains(crate::proxy::verbosity::STEER),
973            "treatment arm must receive the verbosity steer"
974        );
975
976        // Steer on, holdout = 1.0 → everyone Control → byte-unchanged, no steer.
977        crate::core::config::Config::update_global(|c| {
978            c.proxy.output_holdout = Some(1.0);
979        })
980        .unwrap();
981        let (out2, _o, _c) = compress_request_body(req, bytes.len());
982        let v2: Value = serde_json::from_slice(&out2).unwrap();
983        assert!(
984            !v2["messages"][0]["content"]
985                .as_str()
986                .unwrap()
987                .contains(crate::proxy::verbosity::STEER),
988            "control arm must NOT be steered (measurement baseline)"
989        );
990    }
991
992    /// A system prompt comfortably over Anthropic's minimum cacheable size, so
993    /// the #939 breakpoint gate fires.
994    fn cacheable_system() -> String {
995        "You are a careful, senior software engineer who writes maintainable code. ".repeat(400)
996    }
997
998    #[test]
999    fn cache_breakpoint_injected_on_unanchored_system_when_opt_in() {
1000        // #939: opt-in on AND the client set no cache_control of its own → exactly
1001        // one ephemeral breakpoint lands on `system`, wrapping the verbatim text.
1002        let _iso = crate::core::data_dir::isolated_data_dir();
1003        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_BREAKPOINT");
1004        let body = serde_json::json!({
1005            "model": "claude-opus-4-8",
1006            "system": cacheable_system(),
1007            "messages": [{"role": "user", "content": "Refactor the parser."}]
1008        });
1009        let bytes = serde_json::to_vec(&body).unwrap();
1010
1011        crate::core::config::Config::update_global(|c| c.proxy.cache_breakpoint = Some(true))
1012            .unwrap();
1013        let (out, _o, _c) = compress_request_body(body, bytes.len());
1014        let v: Value = serde_json::from_slice(&out).unwrap();
1015
1016        assert_eq!(
1017            v["system"][0]["cache_control"]["type"], "ephemeral",
1018            "an unanchored system prompt must receive one ephemeral breakpoint"
1019        );
1020        assert!(
1021            v["system"][0]["text"]
1022                .as_str()
1023                .unwrap()
1024                .contains("senior software engineer"),
1025            "the system text must be preserved verbatim under the marker"
1026        );
1027    }
1028
1029    #[test]
1030    fn cache_breakpoint_off_by_default_is_byte_unchanged() {
1031        // Default off → the request must be byte-identical (no system reshape),
1032        // preserving the meter-only / cache-stable contract.
1033        let _iso = crate::core::data_dir::isolated_data_dir();
1034        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_BREAKPOINT");
1035        let body = serde_json::json!({
1036            "model": "claude-opus-4-8",
1037            "system": cacheable_system(),
1038            "messages": [{"role": "user", "content": "Refactor the parser."}]
1039        });
1040        let bytes = serde_json::to_vec(&body).unwrap();
1041        let (out, _o, _c) = compress_request_body(body, bytes.len());
1042        assert_eq!(
1043            out, bytes,
1044            "default-off must leave the request byte-identical"
1045        );
1046    }
1047
1048    #[test]
1049    fn cache_breakpoint_respects_client_anchor() {
1050        // #939 safety: a client cache_control on a message means `system` is part
1051        // of the already-cached prefix — never add a second, prefix-shifting anchor.
1052        let _iso = crate::core::data_dir::isolated_data_dir();
1053        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_BREAKPOINT");
1054        let body = serde_json::json!({
1055            "model": "claude-opus-4-8",
1056            "system": cacheable_system(),
1057            "messages": [{
1058                "role": "user",
1059                "content": [{
1060                    "type": "text",
1061                    "text": "hello",
1062                    "cache_control": {"type": "ephemeral"}
1063                }]
1064            }]
1065        });
1066        let bytes = serde_json::to_vec(&body).unwrap();
1067        crate::core::config::Config::update_global(|c| c.proxy.cache_breakpoint = Some(true))
1068            .unwrap();
1069        let (out, _o, _c) = compress_request_body(body, bytes.len());
1070        let v: Value = serde_json::from_slice(&out).unwrap();
1071        assert!(
1072            v["system"].is_string(),
1073            "with a client anchor present, system must be left untouched (no second breakpoint)"
1074        );
1075    }
1076
1077    #[test]
1078    fn cache_aligner_measures_without_mutating_body() {
1079        // #940: the volatile-field scan is telemetry-only — enabling it must leave
1080        // the request byte-identical (measurement, not a rewrite), even on a
1081        // volatile-field-rich system prompt.
1082        let _iso = crate::core::data_dir::isolated_data_dir();
1083        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_ALIGNER");
1084        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_BREAKPOINT");
1085        let body = serde_json::json!({
1086            "model": "claude-opus-4-8",
1087            "system": "Today is 2026-06-22. Session 550e8400-e29b-41d4-a716-446655440000.",
1088            "messages": [{"role": "user", "content": "Hello."}]
1089        });
1090        let bytes = serde_json::to_vec(&body).unwrap();
1091        crate::core::config::Config::update_global(|c| c.proxy.cache_aligner = Some(true)).unwrap();
1092        let (out, _o, _c) = compress_request_body(body, bytes.len());
1093        assert_eq!(
1094            out, bytes,
1095            "cache-aligner telemetry must never mutate the request body"
1096        );
1097    }
1098
1099    /// A cacheable-size system prompt that carries one volatile field (a date),
1100    /// so the #974 relocate has something to move and clears the size floor.
1101    fn cacheable_system_with_date() -> String {
1102        format!("Today is 2026-06-27. {}", cacheable_system())
1103    }
1104
1105    fn clear_relocate_env() {
1106        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE");
1107        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_BREAKPOINT");
1108        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_ALIGNER");
1109        crate::test_env::remove_var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT");
1110    }
1111
1112    #[test]
1113    fn cache_align_relocate_moves_volatiles_to_tail_when_opt_in() {
1114        // #974: opt-in on, client anchored nothing → the date leaves the cacheable
1115        // prefix for an uncached tail block, and the stable block carries the one
1116        // ephemeral breakpoint.
1117        let _iso = crate::core::data_dir::isolated_data_dir();
1118        clear_relocate_env();
1119        let body = serde_json::json!({
1120            "model": "claude-opus-4-8",
1121            "system": cacheable_system_with_date(),
1122            "messages": [{"role": "user", "content": "Refactor the parser."}]
1123        });
1124        let bytes = serde_json::to_vec(&body).unwrap();
1125        crate::core::config::Config::update_global(|c| {
1126            c.proxy.cache_align_relocate = Some(true);
1127            c.proxy.output_holdout = Some(0.0);
1128        })
1129        .unwrap();
1130        let (out, _o, _c) = compress_request_body(body, bytes.len());
1131        let v: Value = serde_json::from_slice(&out).unwrap();
1132
1133        assert!(v["system"].is_array(), "system reshaped into a block array");
1134        assert_eq!(v["system"][0]["cache_control"]["type"], "ephemeral");
1135        assert!(
1136            !v["system"][0]["text"]
1137                .as_str()
1138                .unwrap()
1139                .contains("2026-06-27"),
1140            "the volatile date must leave the cacheable prefix"
1141        );
1142        assert!(
1143            v["system"][1].get("cache_control").is_none(),
1144            "the relocated tail block stays uncached"
1145        );
1146        assert!(
1147            v["system"][1]["text"]
1148                .as_str()
1149                .unwrap()
1150                .contains("2026-06-27"),
1151            "the date must be re-stated in the tail"
1152        );
1153    }
1154
1155    #[test]
1156    fn cache_align_relocate_off_by_default_is_byte_unchanged() {
1157        // Default off → byte-identical request, preserving the cache-stable
1158        // contract even with a volatile-rich system prompt.
1159        let _iso = crate::core::data_dir::isolated_data_dir();
1160        clear_relocate_env();
1161        let body = serde_json::json!({
1162            "model": "claude-opus-4-8",
1163            "system": cacheable_system_with_date(),
1164            "messages": [{"role": "user", "content": "Refactor the parser."}]
1165        });
1166        let bytes = serde_json::to_vec(&body).unwrap();
1167        let (out, _o, _c) = compress_request_body(body, bytes.len());
1168        assert_eq!(
1169            out, bytes,
1170            "default-off must leave the request byte-identical"
1171        );
1172    }
1173
1174    #[test]
1175    fn cache_align_relocate_skips_control_arm() {
1176        // #895 holdout: a control-arm conversation must be byte-unchanged so its
1177        // cache behaviour is the measurement baseline.
1178        let _iso = crate::core::data_dir::isolated_data_dir();
1179        clear_relocate_env();
1180        let body = serde_json::json!({
1181            "model": "claude-opus-4-8",
1182            "system": cacheable_system_with_date(),
1183            "messages": [{"role": "user", "content": "Refactor the parser."}]
1184        });
1185        let bytes = serde_json::to_vec(&body).unwrap();
1186        crate::core::config::Config::update_global(|c| {
1187            c.proxy.cache_align_relocate = Some(true);
1188            c.proxy.output_holdout = Some(1.0);
1189        })
1190        .unwrap();
1191        let (out, _o, _c) = compress_request_body(body, bytes.len());
1192        let v: Value = serde_json::from_slice(&out).unwrap();
1193        assert!(
1194            v["system"].is_string(),
1195            "control arm must not be relocated (measurement baseline)"
1196        );
1197    }
1198
1199    #[test]
1200    fn cache_align_relocate_respects_client_anchor() {
1201        // Safety: a client cache_control means `system` is part of the cached
1202        // prefix — never relocate it (that would shift the cached prefix, #448).
1203        let _iso = crate::core::data_dir::isolated_data_dir();
1204        clear_relocate_env();
1205        let body = serde_json::json!({
1206            "model": "claude-opus-4-8",
1207            "system": cacheable_system_with_date(),
1208            "messages": [{
1209                "role": "user",
1210                "content": [{
1211                    "type": "text",
1212                    "text": "hello",
1213                    "cache_control": {"type": "ephemeral"}
1214                }]
1215            }]
1216        });
1217        let bytes = serde_json::to_vec(&body).unwrap();
1218        crate::core::config::Config::update_global(|c| {
1219            c.proxy.cache_align_relocate = Some(true);
1220            c.proxy.output_holdout = Some(0.0);
1221        })
1222        .unwrap();
1223        let (out, _o, _c) = compress_request_body(body, bytes.len());
1224        let v: Value = serde_json::from_slice(&out).unwrap();
1225        assert!(
1226            v["system"].is_string(),
1227            "with a client anchor present, system must be left untouched"
1228        );
1229    }
1230
1231    #[test]
1232    fn cache_align_relocate_composes_with_breakpoint_to_one_anchor() {
1233        // Both opt-ins on: relocate adds the breakpoint to the stable block, so the
1234        // #939 injection sees an anchored prefix and stays a no-op — exactly one
1235        // breakpoint, on the stable block, with the volatile tail uncached.
1236        let _iso = crate::core::data_dir::isolated_data_dir();
1237        clear_relocate_env();
1238        let body = serde_json::json!({
1239            "model": "claude-opus-4-8",
1240            "system": cacheable_system_with_date(),
1241            "messages": [{"role": "user", "content": "Refactor the parser."}]
1242        });
1243        let bytes = serde_json::to_vec(&body).unwrap();
1244        crate::core::config::Config::update_global(|c| {
1245            c.proxy.cache_align_relocate = Some(true);
1246            c.proxy.cache_breakpoint = Some(true);
1247            c.proxy.output_holdout = Some(0.0);
1248        })
1249        .unwrap();
1250        let (out, _o, _c) = compress_request_body(body, bytes.len());
1251        let v: Value = serde_json::from_slice(&out).unwrap();
1252        let blocks = v["system"].as_array().expect("system is a block array");
1253        assert_eq!(blocks.len(), 2, "stable block + volatile tail");
1254        assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
1255        assert!(
1256            blocks[1].get("cache_control").is_none(),
1257            "no second breakpoint — the tail stays uncached"
1258        );
1259    }
1260}