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