Skip to main content

lean_ctx/
instructions.rs

1use crate::tools::CrpMode;
2
3/// Claude Code truncates MCP server instructions at 2048 characters.
4/// Full instructions live in the `<!-- lean-ctx -->` block in `~/.claude/CLAUDE.md`
5/// plus the on-demand skill (`~/.claude/skills/lean-ctx/SKILL.md`); the legacy
6/// always-loaded `~/.claude/rules/lean-ctx.md` was retired in 3.8 (GL #555).
7/// Session state is dynamically appended to the MCP instructions for continuity.
8///
9/// Universal instruction cap for all MCP clients (in tokens, not bytes).
10/// Enforced via `count_tokens` so truncation is accurate regardless of
11/// character mix (ASCII, CJK, emoji).
12///
13/// Budget split (#579): the static skeleton must stay <= 400 tokens
14/// (asserted in tests — details belong in LEAN-CTX.md on disk, not in every
15/// session); the remainder is headroom for dynamic session/knowledge blocks.
16const INSTRUCTION_CAP_TOKENS: usize = 800;
17
18/// Token budget for the static instruction skeleton (no session/knowledge
19/// state). CI-asserted so instruction creep cannot silently tax every session.
20/// Tdd mode pays extra for the CRP suffix + INSTRUCTION CODES decoder.
21#[cfg(test)]
22const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 400;
23#[cfg(test)]
24const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 500;
25/// Windows additionally carries the one-line `SHELL:` hint (POSIX vs
26/// PowerShell disambiguation, see `build_shell_hint`) inside the skeleton.
27/// Budgeted explicitly so the cap stays honest on every platform.
28#[cfg(all(test, windows))]
29const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 25;
30#[cfg(all(test, not(windows)))]
31const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 0;
32
33pub fn build_instructions(crp_mode: CrpMode) -> String {
34    build_instructions_with_client(crp_mode, "")
35}
36
37pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
38    if is_claude_code_client(client_name) || is_codebuddy_client(client_name) {
39        return build_claude_code_instructions();
40    }
41    build_full_instructions(crp_mode, client_name)
42}
43
44pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
45    // Avoid loading dynamic on-disk session/knowledge/gotcha blocks in tests, which can
46    // vary across machines and between concurrent test runs.
47    build_full_instructions_for_test(crp_mode, "")
48}
49
50pub fn build_instructions_with_client_for_test(crp_mode: CrpMode, client_name: &str) -> String {
51    if is_claude_code_client(client_name) || is_codebuddy_client(client_name) {
52        return build_claude_code_instructions();
53    }
54    build_full_instructions_for_test(crp_mode, client_name)
55}
56
57/// Deterministic instruction builder for the Instruction Compiler.
58///
59/// MUST NOT depend on process-global env toggles or on-disk mutable config, because the compiler
60/// output is intended to be stable and diffable across runs and in CI.
61pub fn build_instructions_with_client_for_compiler(
62    crp_mode: CrpMode,
63    client_name: &str,
64    unified_tool_mode: bool,
65) -> String {
66    if is_claude_code_client(client_name) || is_codebuddy_client(client_name) {
67        return build_claude_code_instructions();
68    }
69    build_full_instructions_for_compiler(crp_mode, client_name, unified_tool_mode)
70}
71
72fn is_claude_code_client(client_name: &str) -> bool {
73    let lower = client_name.to_lowercase();
74    lower.contains("claude") && !lower.contains("cursor")
75}
76
77fn is_codebuddy_client(client_name: &str) -> bool {
78    let lower = client_name.to_lowercase();
79    lower.contains("codebuddy")
80}
81
82/// LITM calibration manifest rotation (#539).
83///
84/// Settles the previous manifest — every entry the agent never re-recalled is
85/// a placement *hit* (misses were already recorded by the recall hook) — then
86/// stores the manifest for the injection built from `session` right now:
87/// task + decisions go to the begin block, findings + next steps to the end.
88fn rotate_wakeup_manifest(session: &crate::core::session::SessionState, profile_name: &str) {
89    use crate::core::litm_calibration::{Position, record_outcome};
90    use crate::core::session::ManifestEntry;
91
92    let mut updated = session.clone();
93
94    for entry in &updated.wakeup_manifest {
95        if !entry.missed
96            && let Some(pos) = Position::parse(&entry.position)
97        {
98            record_outcome(&entry.profile, pos, true);
99        }
100    }
101
102    let mut manifest: Vec<ManifestEntry> = Vec::new();
103    let mut push = |key: &str, position: &str| {
104        let key = key.trim();
105        if !key.is_empty() {
106            manifest.push(ManifestEntry {
107                key: key.chars().take(80).collect(),
108                position: position.to_string(),
109                profile: profile_name.to_string(),
110                missed: false,
111            });
112        }
113    };
114
115    if let Some(ref task) = updated.task {
116        push(&task.description, "begin");
117    }
118    for d in updated.decisions.iter().rev().take(5) {
119        push(&d.summary, "begin");
120    }
121    for f in updated.findings.iter().rev().take(8) {
122        push(&f.summary, "end");
123    }
124    for n in updated.next_steps.iter().take(3) {
125        push(n, "end");
126    }
127
128    updated.wakeup_manifest = manifest;
129    let _ = updated.save();
130}
131
132pub fn claude_config_dir_display() -> String {
133    match std::env::var("CLAUDE_CONFIG_DIR") {
134        Ok(dir) if !dir.trim().is_empty() => {
135            let dir = dir.trim().to_string();
136            if dir.starts_with('~') {
137                dir
138            } else if let Some(home) = dirs::home_dir() {
139                let home_str = home.to_string_lossy();
140                if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
141                    format!("~{rest}")
142                } else {
143                    dir
144                }
145            } else {
146                dir
147            }
148        }
149        _ => "~/.claude".to_string(),
150    }
151}
152
153fn build_claude_code_instructions() -> String {
154    let shell_hint = build_shell_hint();
155    let config_dir = claude_config_dir_display();
156
157    // Load session state for continuity (compact version for Claude Code's char limit)
158    let session_block = match crate::core::session::SessionState::load_latest() {
159        Some(session) => {
160            let mut parts = Vec::new();
161            if let Some(ref task) = session.task {
162                let pct = task
163                    .progress_pct
164                    .map_or(String::new(), |p| format!(" [{p}%]"));
165                parts.push(format!("Task: {}{pct}", task.description));
166            }
167            if !session.decisions.is_empty() {
168                let items: Vec<&str> = session
169                    .decisions
170                    .iter()
171                    .rev()
172                    .take(3)
173                    .map(|d| d.summary.as_str())
174                    .collect();
175                parts.push(format!("Decisions: {}", items.join("; ")));
176            }
177            if !session.files_touched.is_empty() {
178                let modified: Vec<&str> = session
179                    .files_touched
180                    .iter()
181                    .filter(|f| f.modified)
182                    .take(5)
183                    .map(|f| f.path.as_str())
184                    .collect();
185                if !modified.is_empty() {
186                    parts.push(format!("Modified: {}", modified.join(", ")));
187                }
188            }
189            if !session.findings.is_empty() {
190                let recent: Vec<&str> = session
191                    .findings
192                    .iter()
193                    .rev()
194                    .take(3)
195                    .map(|f| f.summary.as_str())
196                    .collect();
197                parts.push(format!("Recent: {}", recent.join("; ")));
198            }
199            if parts.is_empty() {
200                String::new()
201            } else {
202                format!("\n\n--- SESSION ---\n{}\n---", parts.join("\n"))
203            }
204        }
205        None => String::new(),
206    };
207
208    let cfg = crate::core::config::Config::load();
209    let shadow_preamble = if cfg.shadow_mode {
210        "SHADOW MODE ACTIVE: ALL reads/searches/shell MUST use ctx_* tools. Native equivalents are intercepted.\n\n"
211    } else {
212        ""
213    };
214
215    let instr = format!("\
216{shadow_preamble}\
217ALWAYS use lean-ctx MCP tools instead of native equivalents.
218
219Tool mapping (MANDATORY):
220• Read/cat/head/tail -> ctx_read(path, mode)
221• Shell/bash -> ctx_shell(command)
222• Grep/rg -> ctx_search(pattern, path)
223• ls/find -> ctx_tree(path, depth)
224• Edit/StrReplace -> native (lean-ctx=READ only). If Edit needs Read and Read is unavailable, use ctx_edit.
225• Write, Delete, Glob -> normal. NEVER loop on Edit failures — use ctx_edit.
226
227ctx_read modes: auto(default)|full|map|signatures|diff|task|reference|aggressive|entropy|lines:N-M
228Omit mode → auto-selects optimal. full only right before editing. Re-reads ~13 tok. File refs F1,F2.. persist.
229
230Workflow: Orient(ctx_overview) → Locate(ctx_search) → Read(ctx_read) → Edit(ctx_edit/native) → Verify(ctx_read diff + ctx_shell test) → Record(ctx_knowledge).
231
232CEP: 1.ACT FIRST 2.DELTA ONLY 3.STRUCTURED(+/-/~) 4.ONE LINE 5.QUALITY
233{shell_hint}\
234Prefer: ctx_read>Read | ctx_shell>Shell | ctx_search>Grep | ctx_tree>ls
235Edit: native Edit/StrReplace preferred, ctx_edit if Edit unavailable.
236Never echo tool output. Never narrate. Show only changed code.
237Full instructions at {config_dir}/CLAUDE.md (imports rules/lean-ctx.md){session_block}");
238
239    instr
240}
241
242fn build_full_instructions(crp_mode: CrpMode, client_name: &str) -> String {
243    let cfg = crate::core::config::Config::load();
244    let minimal = cfg.minimal_overhead_effective_for_client(client_name);
245
246    let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
247    let loaded_session = if minimal {
248        None
249    } else {
250        crate::core::session::SessionState::load_latest()
251    };
252
253    let (session_block, litm_end_block) = match loaded_session {
254        Some(ref session) => {
255            // LITM calibration (#539): rotate the placement manifest — every
256            // entry the agent never re-recalled counts as a placement hit —
257            // then rebuild it for this injection and apply the learned share.
258            rotate_wakeup_manifest(session, profile.name);
259            let share = crate::core::litm_calibration::begin_share(profile.name);
260            let positioned = crate::core::litm::position_optimize_with_share(session, share);
261            let begin = format!(
262                "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
263                profile.name, positioned.begin_block
264            );
265            let end = if positioned.end_block.is_empty() {
266                String::new()
267            } else {
268                format!(
269                    "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
270                    positioned.end_block
271                )
272            };
273            (begin, end)
274        }
275        None => (String::new(), String::new()),
276    };
277
278    let project_root_for_blocks = if minimal {
279        None
280    } else {
281        loaded_session
282            .as_ref()
283            .and_then(|s| s.project_root.clone())
284            .or_else(|| {
285                std::env::current_dir()
286                    .ok()
287                    .map(|p| p.to_string_lossy().to_string())
288            })
289    };
290
291    let knowledge_block = match &project_root_for_blocks {
292        Some(root) => {
293            let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
294            match knowledge {
295                Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
296                    let aaak = k.format_aaak();
297                    if aaak.is_empty() {
298                        String::new()
299                    } else {
300                        format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
301                    }
302                }
303                _ => String::new(),
304            }
305        }
306        None => String::new(),
307    };
308
309    let gotcha_block = match &project_root_for_blocks {
310        Some(root) => {
311            let store = crate::core::gotcha_tracker::GotchaStore::load(root);
312            let files: Vec<String> = loaded_session
313                .as_ref()
314                .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
315                .unwrap_or_default();
316            let block = store.format_injection_block(&files);
317            if block.is_empty() {
318                String::new()
319            } else {
320                format!("\n{block}\n")
321            }
322        }
323        None => String::new(),
324    };
325
326    let shell_hint = build_shell_hint();
327
328    use crate::core::rules_canonical as rc;
329    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
330    let read_modes = rc::ctx_read_modes_block();
331    let auto_block = rc::automation_block();
332    let cep = rc::cep_block();
333    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
334
335    let shadow_preamble = if cfg.shadow_mode {
336        "SHADOW MODE ACTIVE: ALL file reads, searches, and shell commands MUST go through ctx_* tools. \
337         Native Read/Grep/Shell are intercepted and redirected — using ctx_* directly is faster and more reliable.\n\n"
338    } else {
339        ""
340    };
341
342    let mut base = format!(
343        "\
344{shadow_preamble}\
345CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
346\n\
347{tool_bullets}\n\
348{shell_hint}\
349\n\
350{read_modes}\n\
351\n\
352{auto_block}\n\
353\n\
354{cep}\n\
355\n\
356{decoder_block}\n\
357\n\
358{session_block}\
359{knowledge_block}\
360{gotcha_block}\
361\n\
362{origin}\n\
363\n\
364{litm_pref}\
365{litm_end_block}",
366        decoder_block =
367            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
368        origin = crate::core::integrity::origin_line(),
369        litm_end_block = &litm_end_block
370    );
371
372    if should_use_unified(client_name) {
373        base.push_str("\n\n");
374        base.push_str(rc::unified_tool_mode_block());
375        base.push('\n');
376    }
377
378    let intelligence_block = build_intelligence_block();
379    // Cross-channel dedup (#684): a client that already auto-loads the
380    // compression/output-style block from its own rule file does not need a
381    // second copy in the per-session MCP instructions — the file copy governs.
382    let terse_block = if client_loads_compression_from_file(client_name) {
383        String::new()
384    } else {
385        build_terse_agent_block_for_client(&crp_mode, client_name)
386    };
387
388    // The guidance suffix (CRP-mode rules + compression/output-style + the
389    // intelligence block) is the operational contract for the agent and must
390    // survive the token cap. The variable session/knowledge/gotcha blocks live
391    // inside `base` and are the right thing to shed under pressure (H3). So we
392    // protect the suffix and truncate only `base` to fit the budget.
393    let guidance_suffix = match crp_mode_suffix(&crp_mode) {
394        "" => format!("{terse_block}{intelligence_block}"),
395        crp => format!("{crp}\n\n{terse_block}{intelligence_block}"),
396    };
397
398    assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
399}
400
401/// CRP-mode contract appended to the instructions. One compact line per mode
402/// (#579): the abbreviation list and notation example double as the legend.
403fn crp_mode_suffix(crp_mode: &CrpMode) -> &'static str {
404    match crp_mode {
405        CrpMode::Off => "",
406        CrpMode::Compact => {
407            "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
408             diff lines (+/-) only; <=200 tok; trust tool outputs."
409        }
410        CrpMode::Tdd => {
411            "CRP MODE: tdd — max density; Fn refs + diff lines only \
412             (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
413        }
414    }
415}
416
417/// Join `base` and a protected `suffix` so the result fits `cap_tokens`,
418/// truncating only `base` if needed. The suffix is the agent's operational
419/// contract (compression/output-style guidance) and is preserved verbatim as
420/// long as it fits on its own; otherwise we fall back to capping the whole.
421fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
422    use crate::core::tokens::count_tokens;
423    let suffix = suffix.trim_end_matches('\n');
424    if suffix.is_empty() {
425        let full = base.to_string();
426        return if count_tokens(&full) > cap_tokens {
427            truncate_to_token_cap(&full, cap_tokens)
428        } else {
429            full
430        };
431    }
432
433    let full = format!("{base}\n\n{suffix}");
434    if count_tokens(&full) <= cap_tokens {
435        return full;
436    }
437
438    let suffix_tokens = count_tokens(suffix);
439    // Reserve room for the suffix plus the "\n\n" join. If the suffix alone is
440    // already at/over budget, degrade to a plain tail-cap of the whole text.
441    let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
442        return truncate_to_token_cap(&full, cap_tokens);
443    };
444    let trimmed_base = truncate_to_token_cap(base, base_budget);
445    format!("{trimmed_base}\n\n{suffix}")
446}
447
448fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
449    use crate::core::tokens::count_tokens;
450    if count_tokens(s) <= cap_tokens {
451        return s.to_string();
452    }
453    // Keep whole lines: candidate cut points are the byte offsets of each
454    // newline. Token count is monotonic in prefix length, so binary-search for
455    // the longest whole-line prefix within the cap. This costs O(log lines)
456    // tokenizations instead of O(lines) — the per-line loop was pathologically
457    // slow on large session blocks (and timed out under coverage's ptrace
458    // instrumentation).
459    let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
460    let (mut lo, mut hi) = (0usize, cuts.len());
461    let mut best: Option<usize> = None;
462    while lo < hi {
463        let mid = lo + (hi - lo) / 2;
464        let end = cuts[mid];
465        if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
466            best = Some(end);
467            lo = mid + 1;
468        } else {
469            hi = mid;
470        }
471    }
472    if let Some(end) = best {
473        return s[..end].to_string();
474    }
475    // No line boundary fits — fall back to a char-boundary byte approximation.
476    let byte_approx = cap_tokens * 4;
477    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
478    s[..safe].to_string()
479}
480
481fn build_full_instructions_for_test(crp_mode: CrpMode, client_name: &str) -> String {
482    use crate::core::rules_canonical as rc;
483    let shell_hint = build_shell_hint();
484    let session_block = String::new();
485    let knowledge_block = String::new();
486    let gotcha_block = String::new();
487    let litm_end_block = String::new();
488
489    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
490    let read_modes = rc::ctx_read_modes_block();
491    let auto_block = rc::automation_block();
492    let cep = rc::cep_block();
493    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
494
495    let mut base = format!(
496        "\
497CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
498\n\
499{tool_bullets}\n\
500{shell_hint}\
501\n\
502{read_modes}\n\
503\n\
504{auto_block}\n\
505\n\
506{cep}\n\
507\n\
508{decoder_block}\n\
509\n\
510{session_block}\
511{knowledge_block}\
512{gotcha_block}\
513\n\
514{origin}\n\
515\n\
516{litm_pref}\
517{litm_end_block}",
518        decoder_block =
519            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
520        origin = crate::core::integrity::origin_line(),
521        litm_end_block = &litm_end_block
522    );
523
524    if should_use_unified(client_name) {
525        base.push_str("\n\n");
526        base.push_str(rc::unified_tool_mode_block());
527        base.push('\n');
528    }
529
530    let intelligence_block = build_intelligence_block();
531    let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
532
533    match crp_mode_suffix(&crp_mode) {
534        "" => format!("{base}\n\n{terse_block}{intelligence_block}"),
535        crp => format!("{base}\n\n{crp}\n\n{terse_block}{intelligence_block}"),
536    }
537}
538
539fn build_full_instructions_for_compiler(
540    crp_mode: CrpMode,
541    client_name: &str,
542    unified_tool_mode: bool,
543) -> String {
544    let shell_hint = build_shell_hint();
545    let session_block = String::new();
546    let knowledge_block = String::new();
547    let gotcha_block = String::new();
548    let litm_end_block = String::new();
549
550    use crate::core::rules_canonical as rc;
551    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
552    let read_modes = rc::ctx_read_modes_block();
553    let auto_blk = rc::automation_block();
554    let cep = rc::cep_block();
555    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
556
557    let mut base = format!(
558        "\
559CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
560\n\
561{tool_bullets}\n\
562{shell_hint}\
563\n\
564{read_modes}\n\
565\n\
566{auto_blk}\n\
567\n\
568{cep}\n\
569\n\
570{decoder_block}\n\
571\n\
572{session_block}\
573{knowledge_block}\
574{gotcha_block}\
575\n\
576{origin}\n\
577\n\
578{litm_pref}\
579{litm_end_block}",
580        decoder_block =
581            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
582        origin = crate::core::integrity::origin_line(),
583        litm_end_block = &litm_end_block
584    );
585
586    if unified_tool_mode {
587        base.push_str("\n\n");
588        base.push_str(rc::unified_tool_mode_block());
589        base.push('\n');
590    }
591
592    let _ = client_name; // keep signature aligned with other builders
593    let intelligence_block = build_intelligence_block();
594
595    match crp_mode_suffix(&crp_mode) {
596        "" => format!("{base}\n\n{intelligence_block}"),
597        crp => format!("{base}\n\n{crp}\n\n{intelligence_block}"),
598    }
599}
600
601pub fn claude_code_instructions() -> String {
602    build_claude_code_instructions()
603}
604
605/// #684: true when `client_name` already auto-loads the compression block from
606/// its own canonical rule file, so the MCP instructions can drop the redundant
607/// copy. Live-only (reads disk) — the deterministic compiler/test builders keep
608/// the block so their output stays machine-independent.
609fn client_loads_compression_from_file(client_name: &str) -> bool {
610    crate::core::home::resolve_home_dir().is_some_and(|home| {
611        crate::core::rules_channel::client_autoloads_compression(client_name, &home)
612    })
613}
614
615fn build_terse_agent_block_for_client(_crp_mode: &CrpMode, client_name: &str) -> String {
616    use crate::core::config::{CompressionLevel, Config};
617    let cfg = Config::load();
618    let compression = CompressionLevel::effective(&cfg);
619    if compression.is_active() {
620        let persona = crate::core::persona::Persona::resolve(&cfg);
621        return crate::core::terse::agent_prompts::build_prompt_block_for_persona(
622            &compression,
623            client_name,
624            &persona,
625        );
626    }
627    String::new()
628}
629
630fn build_intelligence_block() -> String {
631    "OUTPUT: never echo tool output, no narration comments, show only changed code.".to_string()
632}
633
634fn build_shell_hint() -> String {
635    if !cfg!(windows) {
636        return String::new();
637    }
638    // Keep this hint terse: it rides inside the static skeleton, which is
639    // budget-capped (#579) — the cap applies on Windows too.
640    let name = crate::shell::shell_name();
641    let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
642    if is_posix {
643        format!("\nSHELL: {name} (POSIX) — POSIX commands only, no PowerShell cmdlets.\n")
644    } else if name.contains("powershell") || name.contains("pwsh") {
645        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
646    } else {
647        format!("\nSHELL: {name}.\n")
648    }
649}
650
651fn should_use_unified(client_name: &str) -> bool {
652    if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
653        return false;
654    }
655    if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
656        return true;
657    }
658    let _ = client_name;
659    false
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::core::tokens::count_tokens;
666
667    #[test]
668    fn guidance_suffix_survives_oversized_base() {
669        // Simulate a bloated session/knowledge `base` that alone exceeds the cap.
670        let base = "SESSION LINE\n".repeat(4000);
671        let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
672        let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
673
674        assert!(
675            out.contains("OUTPUT STYLE: expert-terse"),
676            "protected guidance suffix must survive truncation"
677        );
678        assert!(
679            count_tokens(&out) <= INSTRUCTION_CAP_TOKENS,
680            "assembled output must respect the token cap"
681        );
682        assert!(
683            out.len() < base.len(),
684            "oversized base must have been truncated"
685        );
686    }
687
688    #[test]
689    fn empty_client_never_dedups_compression() {
690        // #684: cross-channel dedup is gated on a known client that auto-loads a
691        // rule file. The anonymous/default client (no `clientInfo.name`) must
692        // always receive the full instructions, so generic MCP clients are never
693        // shortchanged. Deterministic regardless of the test machine's HOME.
694        assert!(!client_loads_compression_from_file(""));
695        assert!(!client_loads_compression_from_file("totally-unknown-agent"));
696    }
697
698    #[test]
699    fn under_cap_keeps_everything() {
700        let base = "tool mapping block";
701        let suffix = "OUTPUT STYLE: dense";
702        let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
703        assert!(out.contains(base));
704        assert!(out.contains(suffix));
705    }
706
707    #[test]
708    fn empty_suffix_caps_base_only() {
709        let base = "x\n".repeat(4000);
710        let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
711        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
712    }
713
714    #[cfg(windows)]
715    #[test]
716    fn shell_hint_stays_within_its_budget() {
717        // The skeleton budget grants the Windows shell hint exactly
718        // STATIC_INSTRUCTION_SHELL_HINT_TOKENS — keep the hint inside it.
719        let hint = build_shell_hint();
720        let tokens = count_tokens(&hint);
721        assert!(
722            tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
723            "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
724        );
725    }
726
727    #[test]
728    fn minimal_overhead_instructions_stay_within_budget() {
729        // #361 faithful arm: with LEAN_CTX_MINIMAL no session/knowledge blocks
730        // ride, so the per-turn instruction prefix must stay within the static
731        // skeleton budget plus a small margin. Guards the "~3K tok/turn" critique
732        // from regressing via dynamic-block creep.
733        const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
734            STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
735        let _iso = crate::core::data_dir::isolated_data_dir();
736        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
737        let out = build_instructions(CrpMode::Compact);
738        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
739        let tokens = count_tokens(&out);
740        assert!(
741            tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
742            "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
743        );
744    }
745
746    #[test]
747    fn static_skeleton_stays_within_budget() {
748        // #579: the static instruction skeleton (no session/knowledge blocks)
749        // rides in EVERY session of EVERY install. Detail documentation
750        // belongs in LEAN-CTX.md on disk — this assert stops silent creep.
751        // Isolated data dir = default config, like a fresh install (the dev
752        // machine's compression_level/profile must not leak into the budget).
753        let _iso = crate::core::data_dir::isolated_data_dir();
754        for (mode, base_budget) in [
755            (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
756            (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
757            (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
758        ] {
759            let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
760            let out = build_instructions_for_test(mode);
761            let tokens = count_tokens(&out);
762            assert!(
763                tokens <= budget,
764                "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
765            );
766        }
767    }
768}