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::compress::compress_tool_result;
11use super::forward;
12use super::tool_kind::{self, ToolResultKind, should_protect};
13use super::{cache_safety, prose};
14use crate::core::config::{HistoryMode, ProseRole};
15
16pub async fn handler(
17    State(state): State<ProxyState>,
18    req: Request<Body>,
19) -> Result<Response, StatusCode> {
20    let upstream = state.anthropic_upstream();
21    forward::forward_request(
22        State(state),
23        req,
24        &upstream,
25        "/v1/messages",
26        compress_request_body,
27        "Anthropic",
28        &[],
29    )
30    .await
31}
32
33fn compress_request_body(parsed: Value, original_size: usize) -> (Vec<u8>, usize, usize) {
34    let mut doc = parsed;
35    let mut modified = false;
36
37    // Opt-in per-role prose aggressiveness (#710). Both default to `None`, in
38    // which case nothing below fires and the body is byte-for-byte unchanged.
39    let cfg = crate::core::config::Config::load();
40    let system_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::System);
41    let user_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::User);
42    let live_compress = cfg.proxy.live_compresses();
43    let mode = cfg.proxy.resolved_history_mode();
44    // #895 Track B: output-savings holdout arm, from the pristine body (before any
45    // mutation below) so it matches the arm the response meter records. Control
46    // conversations skip output-shaping (effort + verbosity steer) but are still
47    // metered. Default holdout=0 → always Treatment (no behaviour change).
48    let arm = super::holdout::assign(
49        &super::holdout::anthropic_key(&doc),
50        cfg.proxy.output_holdout_fraction(),
51    );
52    // #493: in-band CCR expansion (opt-in). Splice any <lc_expand:HASH> the model
53    // echoed back into the verbatim original from the local tee store. A strict
54    // no-op when no marker is present (byte-identical body → cache-safe). Runs
55    // before the meter-only short-circuit so an explicit expand request is
56    // honored even when the proxy is otherwise byte-passthrough.
57    if cfg.proxy.ccr_inband_enabled() {
58        modified |= super::ccr::splice_inband_in_place(&mut doc);
59    }
60    // #834: cache-safe cross-provider effort control. Default off → no-op. The
61    // value is a constant, so it never perturbs the prompt-cache prefix; it only
62    // dials an *existing* adaptive thinking request (never enables thinking the
63    // client didn't ask for).
64    if arm == super::holdout::Arm::Treatment {
65        if let Some(effort) = cfg.proxy.resolved_effort() {
66            modified |= super::effort::apply_anthropic(&mut doc, effort);
67        }
68        // #895: cache-safe wire verbosity steer (constant suffix after the last
69        // cache_control breakpoint). Control arm skips it so the holdout measures
70        // its effect.
71        if cfg.proxy.verbosity_steer_enabled() {
72            modified |= super::verbosity::apply_anthropic(&mut doc);
73        }
74    }
75    // Meter-only (#481): live compression off, no history pruning, no prose
76    // rewriting → forward + usage metering still run, but the body is left
77    // unchanged so the provider prompt-cache prefix stays byte-stable. A pending
78    // in-band splice (`modified`) opts out: the body did change this turn.
79    if !live_compress
80        && mode == HistoryMode::Off
81        && system_aggr.is_none()
82        && user_aggr.is_none()
83        && !modified
84    {
85        let out = serde_json::to_vec(&doc).unwrap_or_default();
86        return (out, original_size, original_size);
87    }
88    let mut prose_segments: u64 = 0;
89
90    // Length of the client's provider-cached message prefix. Needed both for
91    // cache-safe pruning below and to gate top-level system prose: if any
92    // message is client-cached, `system` (which precedes every message) is part
93    // of that cached prefix and must not be rewritten.
94    let cached = doc
95        .get("messages")
96        .and_then(|m| m.as_array())
97        .map_or(0, |m| super::history_prune::cached_prefix_len(m));
98
99    // #480: opt-in big-gap cold-prefix repack. When enabled AND the proxy can
100    // confidently predict (from idle time vs the provider cache TTL) that the
101    // client-cached prefix is already cold, override the normal "never touch the
102    // cached prefix" rule for THIS request and prune/compress the prefix too,
103    // re-seeding a leaner cache. Default-off; never fires without a measured idle
104    // gap past TTL × margin, so warm caches stay byte-stable (#448).
105    let repack = cfg.proxy.repacks_cold_prefix()
106        && doc
107            .get("messages")
108            .and_then(|m| m.as_array())
109            .is_some_and(|m| super::cold_prefix::repack_decision(m, cached));
110    // The prefix length the rewrites below must protect: the full cached prefix
111    // normally, or 0 when we are intentionally repacking the cold prefix.
112    let protect = if repack { 0 } else { cached };
113
114    // System prose: only when nothing is client-cached and the `system` field
115    // carries no `cache_control` of its own — otherwise it anchors the cache.
116    // A cold-prefix repack (`protect == 0` with `repack`) deliberately rewrites
117    // it to re-seed a leaner cache.
118    if let Some(a) = system_aggr
119        && protect == 0
120        && let Some(system) = doc.get_mut("system")
121        && (repack || !prose::value_has_cache_control(system))
122    {
123        let n = prose::compress_system_value(system, a);
124        if n > 0 {
125            prose_segments += u64::from(n);
126            modified = true;
127        }
128    }
129
130    if let Some(messages) = doc.get_mut("messages").and_then(|m| m.as_array_mut()) {
131        // Resolve tool-call id → tool name so file/source reads can be protected
132        // from lossy compression that would force the model to re-read mid-task.
133        let tool_names = tool_kind::anthropic_tool_names(messages);
134
135        // Prune at a frozen, cache-aware boundary by default: Anthropic's
136        // prompt cache matches exact prefixes, so the boundary must not move
137        // every turn (see `history_prune::prune_boundary`). `mode` resolved above.
138        let boundary = super::history_prune::prune_boundary(mode, messages.len());
139        // Never rewrite content the client has marked with `cache_control`:
140        // pruning inside the already-cached prefix invalidates Anthropic's
141        // prompt cache from the first changed message (#448). Pruning therefore
142        // starts after the last breakpoint; with no breakpoint this is 0, i.e.
143        // the previous behaviour.
144        modified |=
145            super::history_prune::prune_history_range(messages, protect, boundary, &tool_names);
146
147        for msg in messages.iter_mut() {
148            let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
149            if role != "user" {
150                continue;
151            }
152
153            if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
154                for block in content.iter_mut() {
155                    if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
156                        continue;
157                    }
158
159                    let name = block
160                        .get("tool_use_id")
161                        .and_then(|v| v.as_str())
162                        .and_then(|id| tool_names.get(id))
163                        .map(String::as_str);
164                    let kind = name.map_or(ToolResultKind::Other, tool_kind::classify_tool_name);
165
166                    // #481: skip live compression when globally off or when the
167                    // originating tool is on the exclusion list (Serena default).
168                    let excluded =
169                        name.is_some_and(|n| cfg.proxy.is_tool_live_compress_excluded(n));
170                    if live_compress
171                        && !excluded
172                        && let Some(inner_content) = block.get_mut("content")
173                    {
174                        modified |= compress_content_field(inner_content, name, kind);
175                    }
176                }
177            }
178        }
179
180        // Frozen-region user prose: free-text `text` blocks of user turns in
181        // `[cached, boundary)`. Cache-safe by construction — the cached prefix
182        // and the live tail (`>= boundary`) are both left intact, and the
183        // rewrite is content-deterministic so the prefix stays byte-stable.
184        if let Some(a) = user_aggr {
185            let end = boundary.min(messages.len());
186            let start = protect.min(end);
187            for msg in &mut messages[start..end] {
188                if msg.get("role").and_then(|r| r.as_str()) == Some("user")
189                    && let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut())
190                {
191                    prose_segments += u64::from(prose::compress_text_blocks(content, a));
192                }
193            }
194        }
195    }
196
197    if prose_segments > 0 {
198        modified = true;
199    }
200    // A deliberate cold-prefix repack (#480) is the one sanctioned exception to
201    // the frozen-window rule; count it on its own gauge so it never dilutes the
202    // cache-safe ratio (which exists to catch *accidental* #448 regressions).
203    // Every other rewrite lands strictly inside the cache-safe frozen window.
204    if repack {
205        cache_safety::record_cold_repack();
206    }
207    cache_safety::record(prose_segments, true);
208
209    let out = serde_json::to_vec(&doc).unwrap_or_default();
210    let compressed_size = if modified { out.len() } else { original_size };
211    (out, original_size, compressed_size)
212}
213
214/// Compresses a tool_result `content` field unless it is a protected file/source
215/// read, which must reach the model intact (it is what gets edited).
216fn compress_content_field(
217    content: &mut Value,
218    tool_name: Option<&str>,
219    kind: ToolResultKind,
220) -> bool {
221    match content {
222        Value::String(s) => {
223            if should_protect(kind, s) {
224                return false;
225            }
226            let compressed = compress_tool_result(s, tool_name);
227            if compressed.len() < s.len() {
228                *s = compressed;
229                return true;
230            }
231            false
232        }
233        Value::Array(arr) => {
234            let mut modified = false;
235            for item in arr.iter_mut() {
236                if item.get("type").and_then(|t| t.as_str()) == Some("text")
237                    && let Some(text) = item
238                        .get_mut("text")
239                        .and_then(|t| t.as_str().map(String::from))
240                {
241                    if should_protect(kind, &text) {
242                        continue;
243                    }
244                    let compressed = compress_tool_result(&text, tool_name);
245                    if compressed.len() < text.len() {
246                        item["text"] = Value::String(compressed);
247                        modified = true;
248                    }
249                }
250            }
251            modified
252        }
253        _ => false,
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn source_file_body() -> Vec<u8> {
262        let code = (0..60)
263            .map(|i| format!("    let binding_{i} = compute_value_{i}(context, options);"))
264            .collect::<Vec<_>>()
265            .join("\n");
266        let body = serde_json::json!({
267            "model": "claude-opus-4-8",
268            "messages": [
269                {
270                    "role": "assistant",
271                    "content": [{"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {"file_path": "src/app.rs"}}]
272                },
273                {
274                    "role": "user",
275                    "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": code}]
276                }
277            ]
278        });
279        serde_json::to_vec(&body).unwrap()
280    }
281
282    #[test]
283    fn read_tool_result_is_never_truncated() {
284        let bytes = source_file_body();
285        let body: Value = serde_json::from_slice(&bytes).unwrap();
286        let (out, _orig, _comp) = compress_request_body(body, bytes.len());
287        let parsed: Value = serde_json::from_slice(&out).unwrap();
288        let content = parsed["messages"][1]["content"][0]["content"]
289            .as_str()
290            .unwrap();
291        assert!(
292            content.contains("binding_59"),
293            "the full source body must survive — refactors need it intact"
294        );
295        assert!(!content.contains("lines omitted"));
296    }
297
298    fn forge_log_body(tool_name: &str) -> Value {
299        // Generic, highly-repetitive log with no `$ cmd` hint, so routing falls
300        // back to the tool name (exercising the foreign-tool classification)
301        // and the generic compressor (not a command-specific pattern).
302        let mut log = String::new();
303        for i in 0..90 {
304            log.push_str(&format!(
305                "INFO  processing item {i}: ok, latency={i}ms, queue depth normal, retries 0\n"
306            ));
307        }
308        serde_json::json!({
309            "messages": [
310                {"role": "assistant", "content": [{"type": "tool_use", "id": "f1", "name": tool_name, "input": {}}]},
311                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "f1", "content": log}]}
312            ]
313        })
314    }
315
316    #[test]
317    fn forge_shell_tool_result_compresses() {
318        // A vendor-prefixed foreign shell tool reaches the proxy; its log output
319        // must still be compressed (rtk/ctx_* never see another server's tools).
320        let body = forge_log_body("forge_shell");
321        let bytes = serde_json::to_vec(&body).unwrap();
322        let (_out, orig, comp) = compress_request_body(body, bytes.len());
323        assert!(comp < orig, "foreign shell output must be compressed");
324    }
325
326    #[test]
327    fn foreign_read_tool_protects_source() {
328        // `forge_read` is classified FileRead via the segment fallback, so the
329        // source body must reach the model intact (it is what gets edited).
330        let code = (0..60)
331            .map(|i| format!("    let binding_{i} = compute_value_{i}(context, options);"))
332            .collect::<Vec<_>>()
333            .join("\n");
334        let body = serde_json::json!({
335            "messages": [
336                {"role": "assistant", "content": [{"type": "tool_use", "id": "r1", "name": "forge_read", "input": {"path": "src/app.rs"}}]},
337                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "r1", "content": code}]}
338            ]
339        });
340        let bytes = serde_json::to_vec(&body).unwrap();
341        let (out, _orig, _comp) = compress_request_body(body, bytes.len());
342        let parsed: Value = serde_json::from_slice(&out).unwrap();
343        let content = parsed["messages"][1]["content"][0]["content"]
344            .as_str()
345            .unwrap();
346        assert!(
347            content.contains("binding_59"),
348            "source body must survive intact"
349        );
350    }
351
352    #[test]
353    fn compress_request_body_is_deterministic() {
354        // tee path depends on the data dir; serialize env access so a parallel
355        // test never swaps LEAN_CTX_DATA_DIR between the two compressions.
356        let _lock = crate::core::data_dir::test_env_lock();
357        // #498: the proxy rewrite must be a pure function of the body so the
358        // provider prompt-cache prefix stays byte-identical across turns.
359        let bytes = serde_json::to_vec(&forge_log_body("Bash")).unwrap();
360        let a = compress_request_body(serde_json::from_slice(&bytes).unwrap(), bytes.len()).0;
361        let b = compress_request_body(serde_json::from_slice(&bytes).unwrap(), bytes.len()).0;
362        assert_eq!(a, b, "identical input must yield byte-identical output");
363    }
364
365    /// A large, highly-compressible foreign log so the live path tees + stubs it.
366    fn big_log() -> String {
367        (0..200)
368            .map(|i| format!("[info] processed item {i:04} ok, latency {i}ms, queue normal"))
369            .collect::<Vec<_>>()
370            .join("\n")
371    }
372
373    #[test]
374    fn inband_ccr_emit_echo_splice_round_trip() {
375        // Full #493 cycle through the real Anthropic request path: a lossy stub
376        // emits an <lc_expand:HASH> marker, the model echoes it, and the proxy
377        // splices the verbatim original back inline on the next request.
378        let _iso = crate::core::data_dir::isolated_data_dir();
379        crate::test_env::remove_var("LEAN_CTX_PROXY_CCR_INBAND");
380        crate::core::config::Config::update_global(|c| {
381            c.proxy.ccr_inband = Some(true);
382        })
383        .unwrap();
384
385        // EMIT: live-compress a foreign tool_result → recovery stub with a marker.
386        let log = big_log();
387        let emit = serde_json::json!({
388            "messages": [
389                {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "bash", "input": {}}]},
390                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": log}]}
391            ]
392        });
393        let bytes = serde_json::to_vec(&emit).unwrap();
394        let (out, _o, _c) = compress_request_body(emit, bytes.len());
395        let emitted: Value = serde_json::from_slice(&out).unwrap();
396        let stub = emitted["messages"][1]["content"][0]["content"]
397            .as_str()
398            .unwrap();
399        assert!(
400            stub.contains("<lc_expand:"),
401            "in-band stub must advertise an echo-able marker: {stub}"
402        );
403        assert!(
404            !stub.contains("/tee/proxy_"),
405            "in-band stub must not leak the unreachable local tee path: {stub}"
406        );
407
408        // The marker the model would copy into its next turn.
409        let start = stub.find("<lc_expand:").unwrap();
410        let end = stub[start..].find('>').unwrap() + start + 1;
411        let marker = &stub[start..end];
412
413        // ECHO + SPLICE: the model echoes the marker; the proxy splices the
414        // verbatim original (recovered from the local tee store) back inline.
415        let echo = serde_json::json!({
416            "messages": [
417                {"role": "user", "content": [{"type": "text", "text": "look again"}]},
418                {"role": "assistant", "content": format!("revisiting that output: {marker}")}
419            ]
420        });
421        let bytes = serde_json::to_vec(&echo).unwrap();
422        let (out, _o, _c) = compress_request_body(echo, bytes.len());
423        let spliced: Value = serde_json::from_slice(&out).unwrap();
424        let assistant = spliced["messages"][1]["content"].as_str().unwrap();
425        assert!(
426            assistant.contains("processed item 0007 ok")
427                && assistant.contains("processed item 0199 ok"),
428            "the verbatim original must be spliced back in full: {assistant}"
429        );
430        assert!(
431            !assistant.contains("<lc_expand:"),
432            "the marker must be consumed by the splice"
433        );
434    }
435
436    #[test]
437    fn inband_marker_less_turn_is_byte_identical_on_or_off() {
438        // Cache-safety (#493): enabling in-band must be a strict no-op on a turn
439        // with no marker — same bytes on the wire, so the provider cache prefix is
440        // never perturbed unless the model actually asked to expand. Uses a body
441        // with nothing to prune/compress, isolating the splice from stub emission.
442        let _iso = crate::core::data_dir::isolated_data_dir();
443        crate::test_env::remove_var("LEAN_CTX_PROXY_CCR_INBAND");
444        let body = serde_json::json!({
445            "messages": [
446                {"role": "user", "content": [{"type": "text", "text": "hello there"}]},
447                {"role": "assistant", "content": "hi — how can I help?"}
448            ]
449        });
450        let bytes = serde_json::to_vec(&body).unwrap();
451
452        crate::core::config::Config::update_global(|c| c.proxy.ccr_inband = Some(false)).unwrap();
453        let off = compress_request_body(body.clone(), bytes.len()).0;
454        crate::core::config::Config::update_global(|c| c.proxy.ccr_inband = Some(true)).unwrap();
455        let on = compress_request_body(body, bytes.len()).0;
456
457        assert_eq!(
458            off, on,
459            "a marker-less request must be byte-identical whether in-band is on or off"
460        );
461    }
462
463    /// Long, duplicate-rich natural-language prose that compresses cleanly.
464    fn big_prose() -> String {
465        let p = "You are a careful, senior software engineer. You always explain your \
466                 reasoning before making changes, you prefer small reviewable diffs, and \
467                 you never introduce mock data or placeholders into production code. ";
468        [p; 6].join("\n")
469    }
470
471    #[test]
472    fn system_prose_compressed_and_assistant_untouched() {
473        let _iso = crate::core::data_dir::isolated_data_dir();
474        crate::core::config::Config::update_global(|c| {
475            c.proxy.role_aggressiveness.system = Some(0.6);
476            c.proxy.role_aggressiveness.user = Some(0.6);
477        })
478        .unwrap();
479
480        let prose = big_prose();
481        let assistant_text = big_prose();
482        let body = serde_json::json!({
483            "model": "claude-opus-4-8",
484            "system": prose,
485            "messages": [
486                {"role": "user", "content": [{"type": "text", "text": prose}]},
487                {"role": "assistant", "content": assistant_text},
488            ]
489        });
490        let bytes = serde_json::to_vec(&body).unwrap();
491        let (out, _orig, _comp) = compress_request_body(body, bytes.len());
492        let parsed: Value = serde_json::from_slice(&out).unwrap();
493
494        assert!(
495            parsed["system"].as_str().unwrap().len() < prose.len(),
496            "system prose must be compressed when enabled"
497        );
498        assert_eq!(
499            parsed["messages"][1]["content"].as_str().unwrap(),
500            assistant_text,
501            "assistant turns must pass through verbatim (#710)"
502        );
503    }
504
505    #[test]
506    fn user_prose_compressed_only_in_frozen_region() {
507        let _iso = crate::core::data_dir::isolated_data_dir();
508        crate::core::config::Config::update_global(|c| {
509            c.proxy.role_aggressiveness.user = Some(0.7);
510        })
511        .unwrap();
512
513        let prose = big_prose();
514        // 30 messages → cache-aware boundary = ((30-8)/16)*16 = 16.
515        let mut messages = Vec::new();
516        for i in 0..30 {
517            let role = if i % 2 == 0 { "user" } else { "assistant" };
518            messages.push(serde_json::json!({
519                "role": role,
520                "content": [{"type": "text", "text": prose}]
521            }));
522        }
523        let body = serde_json::json!({ "messages": messages });
524        let bytes = serde_json::to_vec(&body).unwrap();
525        let (out, _o, _c) = compress_request_body(body, bytes.len());
526        let parsed: Value = serde_json::from_slice(&out).unwrap();
527
528        let frozen_user = parsed["messages"][0]["content"][0]["text"]
529            .as_str()
530            .unwrap();
531        assert!(
532            frozen_user.len() < prose.len(),
533            "user prose in the frozen region must be compressed"
534        );
535        assert_eq!(
536            parsed["messages"][1]["content"][0]["text"]
537                .as_str()
538                .unwrap(),
539            prose,
540            "assistant prose is never compressed"
541        );
542        let live_tail_user = parsed["messages"][28]["content"][0]["text"]
543            .as_str()
544            .unwrap();
545        assert_eq!(
546            live_tail_user, prose,
547            "user prose in the live tail (>= boundary) must be preserved for quality"
548        );
549    }
550
551    #[test]
552    fn client_cached_prefix_disables_system_prose() {
553        let _iso = crate::core::data_dir::isolated_data_dir();
554        crate::core::config::Config::update_global(|c| {
555            c.proxy.role_aggressiveness.system = Some(0.9);
556        })
557        .unwrap();
558
559        let prose = big_prose();
560        let body = serde_json::json!({
561            "system": prose,
562            "messages": [
563                {"role": "user", "content": [
564                    {"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}
565                ]},
566                {"role": "assistant", "content": "ok"}
567            ]
568        });
569        let bytes = serde_json::to_vec(&body).unwrap();
570        let (out, _o, _c) = compress_request_body(body, bytes.len());
571        let parsed: Value = serde_json::from_slice(&out).unwrap();
572        assert_eq!(
573            parsed["system"].as_str().unwrap(),
574            prose,
575            "system must stay verbatim when the client caches a message prefix (#448)"
576        );
577    }
578
579    #[test]
580    fn prose_compression_is_deterministic() {
581        let _iso = crate::core::data_dir::isolated_data_dir();
582        crate::core::config::Config::update_global(|c| {
583            c.proxy.role_aggressiveness.system = Some(0.6);
584        })
585        .unwrap();
586        let prose = big_prose();
587        let mk = || serde_json::json!({"system": prose, "messages": [{"role": "user", "content": "hi"}]});
588        let (a, b) = (mk(), mk());
589        let la = serde_json::to_vec(&a).unwrap().len();
590        let lb = serde_json::to_vec(&b).unwrap().len();
591        assert_eq!(
592            compress_request_body(a, la).0,
593            compress_request_body(b, lb).0,
594            "prose compression must be byte-identical for identical input (#498)"
595        );
596    }
597
598    #[test]
599    fn bash_tool_result_still_compresses() {
600        let log = {
601            let mut s = String::from(
602                "$ 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",
603            );
604            for i in 0..90 {
605                s.push_str(&format!("\tmodified:   src/module_{i}/file_{i}.rs\n"));
606            }
607            s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
608            s
609        };
610        let body = serde_json::json!({
611            "messages": [
612                {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]},
613                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": log}]}
614            ]
615        });
616        let bytes = serde_json::to_vec(&body).unwrap();
617        let (_out, orig, comp) = compress_request_body(body, bytes.len());
618        assert!(comp < orig, "shell output must still be compressed");
619    }
620
621    /// A client-cached message anchors the prefix; `system` precedes it, so the
622    /// cached prefix is `cached > 0` and system prose is normally protected.
623    /// System-prose verbatim-vs-rewritten is therefore a clean binary signal for
624    /// whether the #480 cold-prefix repack fired.
625    ///
626    /// `first_text` must be UNIQUE per test: it is `messages[0]`, which the
627    /// cold-prefix tracker hashes into the conversation key. A shared global
628    /// last-touch store has no test-clear hook (that would race with the unit
629    /// tests), so distinct keys are how parallel tests stay isolated.
630    fn cached_prefix_body(first_text: &str, prose: &str) -> (Vec<Value>, Value) {
631        let messages = vec![
632            serde_json::json!({"role": "user", "content": [
633                {"type": "text", "text": first_text, "cache_control": {"type": "ephemeral"}}
634            ]}),
635            serde_json::json!({"role": "assistant", "content": "ok"}),
636        ];
637        let body = serde_json::json!({ "system": prose, "messages": messages.clone() });
638        (messages, body)
639    }
640
641    #[test]
642    fn cold_prefix_repack_rewrites_protected_system_prose_when_enabled() {
643        let _iso = crate::core::data_dir::isolated_data_dir();
644        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
645        crate::core::config::Config::update_global(|c| {
646            c.proxy.role_aggressiveness.system = Some(0.9);
647            c.proxy.cold_prefix_repack = Some(true);
648        })
649        .unwrap();
650
651        let prose = big_prose();
652        let (messages, body) = cached_prefix_body("cold-repack-enabled-session", &prose);
653        // Predict cold: last touched 3h ago, well past the 5m default TTL × margin.
654        super::super::cold_prefix::test_seed_last_touch(&messages, 3 * 60 * 60);
655
656        let bytes = serde_json::to_vec(&body).unwrap();
657        let (out, _o, _c) = compress_request_body(body, bytes.len());
658        let parsed: Value = serde_json::from_slice(&out).unwrap();
659        assert!(
660            parsed["system"].as_str().unwrap().len() < prose.len(),
661            "a predicted-cold prefix must let the proxy repack the otherwise-protected system prose"
662        );
663    }
664
665    #[test]
666    fn cold_prefix_repack_off_by_default_keeps_prefix_protected() {
667        let _iso = crate::core::data_dir::isolated_data_dir();
668        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
669        crate::core::config::Config::update_global(|c| {
670            c.proxy.role_aggressiveness.system = Some(0.9);
671            c.proxy.cold_prefix_repack = Some(false);
672        })
673        .unwrap();
674
675        let prose = big_prose();
676        let (messages, body) = cached_prefix_body("cold-repack-disabled-session", &prose);
677        // Even with a huge idle gap, default-off must never touch the prefix.
678        super::super::cold_prefix::test_seed_last_touch(&messages, 24 * 60 * 60);
679
680        let bytes = serde_json::to_vec(&body).unwrap();
681        let (out, _o, _c) = compress_request_body(body, bytes.len());
682        let parsed: Value = serde_json::from_slice(&out).unwrap();
683        assert_eq!(
684            parsed["system"].as_str().unwrap(),
685            prose,
686            "with repack off the cached prefix stays byte-stable regardless of idle time (#448)"
687        );
688    }
689
690    #[test]
691    fn cold_prefix_repack_protects_warm_prefix_even_when_enabled() {
692        let _iso = crate::core::data_dir::isolated_data_dir();
693        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
694        crate::core::config::Config::update_global(|c| {
695            c.proxy.role_aggressiveness.system = Some(0.9);
696            c.proxy.cold_prefix_repack = Some(true);
697        })
698        .unwrap();
699
700        let prose = big_prose();
701        let (messages, body) = cached_prefix_body("cold-repack-warm-session", &prose);
702        // Warm: touched 1 minute ago → the prediction must keep protecting.
703        super::super::cold_prefix::test_seed_last_touch(&messages, 60);
704
705        let bytes = serde_json::to_vec(&body).unwrap();
706        let (out, _o, _c) = compress_request_body(body, bytes.len());
707        let parsed: Value = serde_json::from_slice(&out).unwrap();
708        assert_eq!(
709            parsed["system"].as_str().unwrap(),
710            prose,
711            "a warm prefix must stay protected even with repack enabled — only LARGE gaps trigger"
712        );
713    }
714
715    #[test]
716    fn effort_control_dials_adaptive_thinking_only() {
717        // #834 end-to-end: fill output_config.effort when the client already
718        // asked for adaptive thinking, but never enable thinking otherwise.
719        let _iso = crate::core::data_dir::isolated_data_dir();
720        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
721        crate::core::config::Config::update_global(|c| {
722            c.proxy.effort = Some("medium".into());
723        })
724        .unwrap();
725
726        let adaptive = serde_json::json!({
727            "model": "claude-opus-4-8",
728            "thinking": {"type": "adaptive"},
729            "messages": [{"role": "user", "content": "hi"}]
730        });
731        let bytes = serde_json::to_vec(&adaptive).unwrap();
732        let (out, _o, _c) = compress_request_body(adaptive, bytes.len());
733        assert_eq!(
734            serde_json::from_slice::<Value>(&out).unwrap()["output_config"]["effort"],
735            "medium"
736        );
737
738        // No thinking field → the proxy must not add output_config (no surprise
739        // reasoning cost, no 400 risk).
740        let plain = serde_json::json!({
741            "model": "claude-opus-4-8",
742            "messages": [{"role": "user", "content": "hi"}]
743        });
744        let bytes = serde_json::to_vec(&plain).unwrap();
745        let (out, _o, _c) = compress_request_body(plain, bytes.len());
746        assert!(
747            serde_json::from_slice::<Value>(&out)
748                .unwrap()
749                .get("output_config")
750                .is_none()
751        );
752    }
753
754    #[test]
755    fn verbosity_steer_applies_to_treatment_skips_control() {
756        // #895: the holdout control arm must be byte-unchanged (so its output is
757        // the measurement baseline); the treatment arm gets the constant steer.
758        let _iso = crate::core::data_dir::isolated_data_dir();
759        crate::test_env::remove_var("LEAN_CTX_PROXY_VERBOSITY_STEER");
760        crate::test_env::remove_var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT");
761        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
762
763        let req = serde_json::json!({
764            "model": "claude-opus-4-8",
765            "messages": [{"role": "user", "content": "Summarize the design."}]
766        });
767        let bytes = serde_json::to_vec(&req).unwrap();
768
769        // Steer on, holdout = 0 → everyone Treatment → steered.
770        crate::core::config::Config::update_global(|c| {
771            c.proxy.verbosity_steer = Some(true);
772            c.proxy.output_holdout = Some(0.0);
773        })
774        .unwrap();
775        let (out, _o, _c) = compress_request_body(req.clone(), bytes.len());
776        let v: Value = serde_json::from_slice(&out).unwrap();
777        assert!(
778            v["messages"][0]["content"]
779                .as_str()
780                .unwrap()
781                .contains(crate::proxy::verbosity::STEER),
782            "treatment arm must receive the verbosity steer"
783        );
784
785        // Steer on, holdout = 1.0 → everyone Control → byte-unchanged, no steer.
786        crate::core::config::Config::update_global(|c| {
787            c.proxy.output_holdout = Some(1.0);
788        })
789        .unwrap();
790        let (out2, _o, _c) = compress_request_body(req, bytes.len());
791        let v2: Value = serde_json::from_slice(&out2).unwrap();
792        assert!(
793            !v2["messages"][0]["content"]
794                .as_str()
795                .unwrap()
796                .contains(crate::proxy::verbosity::STEER),
797            "control arm must NOT be steered (measurement baseline)"
798        );
799    }
800}