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    let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
380
381    // The guidance suffix (CRP-mode rules + compression/output-style + the
382    // intelligence block) is the operational contract for the agent and must
383    // survive the token cap. The variable session/knowledge/gotcha blocks live
384    // inside `base` and are the right thing to shed under pressure (H3). So we
385    // protect the suffix and truncate only `base` to fit the budget.
386    let guidance_suffix = match crp_mode_suffix(&crp_mode) {
387        "" => format!("{terse_block}{intelligence_block}"),
388        crp => format!("{crp}\n\n{terse_block}{intelligence_block}"),
389    };
390
391    assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
392}
393
394/// CRP-mode contract appended to the instructions. One compact line per mode
395/// (#579): the abbreviation list and notation example double as the legend.
396fn crp_mode_suffix(crp_mode: &CrpMode) -> &'static str {
397    match crp_mode {
398        CrpMode::Off => "",
399        CrpMode::Compact => {
400            "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
401             diff lines (+/-) only; <=200 tok; trust tool outputs."
402        }
403        CrpMode::Tdd => {
404            "CRP MODE: tdd — max density; Fn refs + diff lines only \
405             (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
406        }
407    }
408}
409
410/// Join `base` and a protected `suffix` so the result fits `cap_tokens`,
411/// truncating only `base` if needed. The suffix is the agent's operational
412/// contract (compression/output-style guidance) and is preserved verbatim as
413/// long as it fits on its own; otherwise we fall back to capping the whole.
414fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
415    use crate::core::tokens::count_tokens;
416    let suffix = suffix.trim_end_matches('\n');
417    if suffix.is_empty() {
418        let full = base.to_string();
419        return if count_tokens(&full) > cap_tokens {
420            truncate_to_token_cap(&full, cap_tokens)
421        } else {
422            full
423        };
424    }
425
426    let full = format!("{base}\n\n{suffix}");
427    if count_tokens(&full) <= cap_tokens {
428        return full;
429    }
430
431    let suffix_tokens = count_tokens(suffix);
432    // Reserve room for the suffix plus the "\n\n" join. If the suffix alone is
433    // already at/over budget, degrade to a plain tail-cap of the whole text.
434    let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
435        return truncate_to_token_cap(&full, cap_tokens);
436    };
437    let trimmed_base = truncate_to_token_cap(base, base_budget);
438    format!("{trimmed_base}\n\n{suffix}")
439}
440
441fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
442    use crate::core::tokens::count_tokens;
443    if count_tokens(s) <= cap_tokens {
444        return s.to_string();
445    }
446    // Keep whole lines: candidate cut points are the byte offsets of each
447    // newline. Token count is monotonic in prefix length, so binary-search for
448    // the longest whole-line prefix within the cap. This costs O(log lines)
449    // tokenizations instead of O(lines) — the per-line loop was pathologically
450    // slow on large session blocks (and timed out under coverage's ptrace
451    // instrumentation).
452    let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
453    let (mut lo, mut hi) = (0usize, cuts.len());
454    let mut best: Option<usize> = None;
455    while lo < hi {
456        let mid = lo + (hi - lo) / 2;
457        let end = cuts[mid];
458        if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
459            best = Some(end);
460            lo = mid + 1;
461        } else {
462            hi = mid;
463        }
464    }
465    if let Some(end) = best {
466        return s[..end].to_string();
467    }
468    // No line boundary fits — fall back to a char-boundary byte approximation.
469    let byte_approx = cap_tokens * 4;
470    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
471    s[..safe].to_string()
472}
473
474fn build_full_instructions_for_test(crp_mode: CrpMode, client_name: &str) -> String {
475    use crate::core::rules_canonical as rc;
476    let shell_hint = build_shell_hint();
477    let session_block = String::new();
478    let knowledge_block = String::new();
479    let gotcha_block = String::new();
480    let litm_end_block = String::new();
481
482    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
483    let read_modes = rc::ctx_read_modes_block();
484    let auto_block = rc::automation_block();
485    let cep = rc::cep_block();
486    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
487
488    let mut base = format!(
489        "\
490CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
491\n\
492{tool_bullets}\n\
493{shell_hint}\
494\n\
495{read_modes}\n\
496\n\
497{auto_block}\n\
498\n\
499{cep}\n\
500\n\
501{decoder_block}\n\
502\n\
503{session_block}\
504{knowledge_block}\
505{gotcha_block}\
506\n\
507{origin}\n\
508\n\
509{litm_pref}\
510{litm_end_block}",
511        decoder_block =
512            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
513        origin = crate::core::integrity::origin_line(),
514        litm_end_block = &litm_end_block
515    );
516
517    if should_use_unified(client_name) {
518        base.push_str("\n\n");
519        base.push_str(rc::unified_tool_mode_block());
520        base.push('\n');
521    }
522
523    let intelligence_block = build_intelligence_block();
524    let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
525
526    match crp_mode_suffix(&crp_mode) {
527        "" => format!("{base}\n\n{terse_block}{intelligence_block}"),
528        crp => format!("{base}\n\n{crp}\n\n{terse_block}{intelligence_block}"),
529    }
530}
531
532fn build_full_instructions_for_compiler(
533    crp_mode: CrpMode,
534    client_name: &str,
535    unified_tool_mode: bool,
536) -> String {
537    let shell_hint = build_shell_hint();
538    let session_block = String::new();
539    let knowledge_block = String::new();
540    let gotcha_block = String::new();
541    let litm_end_block = String::new();
542
543    use crate::core::rules_canonical as rc;
544    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
545    let read_modes = rc::ctx_read_modes_block();
546    let auto_blk = rc::automation_block();
547    let cep = rc::cep_block();
548    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
549
550    let mut base = format!(
551        "\
552CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
553\n\
554{tool_bullets}\n\
555{shell_hint}\
556\n\
557{read_modes}\n\
558\n\
559{auto_blk}\n\
560\n\
561{cep}\n\
562\n\
563{decoder_block}\n\
564\n\
565{session_block}\
566{knowledge_block}\
567{gotcha_block}\
568\n\
569{origin}\n\
570\n\
571{litm_pref}\
572{litm_end_block}",
573        decoder_block =
574            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
575        origin = crate::core::integrity::origin_line(),
576        litm_end_block = &litm_end_block
577    );
578
579    if unified_tool_mode {
580        base.push_str("\n\n");
581        base.push_str(rc::unified_tool_mode_block());
582        base.push('\n');
583    }
584
585    let _ = client_name; // keep signature aligned with other builders
586    let intelligence_block = build_intelligence_block();
587
588    match crp_mode_suffix(&crp_mode) {
589        "" => format!("{base}\n\n{intelligence_block}"),
590        crp => format!("{base}\n\n{crp}\n\n{intelligence_block}"),
591    }
592}
593
594pub fn claude_code_instructions() -> String {
595    build_claude_code_instructions()
596}
597
598fn build_terse_agent_block_for_client(_crp_mode: &CrpMode, client_name: &str) -> String {
599    use crate::core::config::{CompressionLevel, Config};
600    let cfg = Config::load();
601    let compression = CompressionLevel::effective(&cfg);
602    if compression.is_active() {
603        let persona = crate::core::persona::Persona::resolve(&cfg);
604        return crate::core::terse::agent_prompts::build_prompt_block_for_persona(
605            &compression,
606            client_name,
607            &persona,
608        );
609    }
610    String::new()
611}
612
613fn build_intelligence_block() -> String {
614    "OUTPUT: never echo tool output, no narration comments, show only changed code.".to_string()
615}
616
617fn build_shell_hint() -> String {
618    if !cfg!(windows) {
619        return String::new();
620    }
621    // Keep this hint terse: it rides inside the static skeleton, which is
622    // budget-capped (#579) — the cap applies on Windows too.
623    let name = crate::shell::shell_name();
624    let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
625    if is_posix {
626        format!("\nSHELL: {name} (POSIX) — POSIX commands only, no PowerShell cmdlets.\n")
627    } else if name.contains("powershell") || name.contains("pwsh") {
628        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
629    } else {
630        format!("\nSHELL: {name}.\n")
631    }
632}
633
634fn should_use_unified(client_name: &str) -> bool {
635    if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
636        return false;
637    }
638    if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
639        return true;
640    }
641    let _ = client_name;
642    false
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use crate::core::tokens::count_tokens;
649
650    #[test]
651    fn guidance_suffix_survives_oversized_base() {
652        // Simulate a bloated session/knowledge `base` that alone exceeds the cap.
653        let base = "SESSION LINE\n".repeat(4000);
654        let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
655        let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
656
657        assert!(
658            out.contains("OUTPUT STYLE: expert-terse"),
659            "protected guidance suffix must survive truncation"
660        );
661        assert!(
662            count_tokens(&out) <= INSTRUCTION_CAP_TOKENS,
663            "assembled output must respect the token cap"
664        );
665        assert!(
666            out.len() < base.len(),
667            "oversized base must have been truncated"
668        );
669    }
670
671    #[test]
672    fn under_cap_keeps_everything() {
673        let base = "tool mapping block";
674        let suffix = "OUTPUT STYLE: dense";
675        let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
676        assert!(out.contains(base));
677        assert!(out.contains(suffix));
678    }
679
680    #[test]
681    fn empty_suffix_caps_base_only() {
682        let base = "x\n".repeat(4000);
683        let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
684        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
685    }
686
687    #[cfg(windows)]
688    #[test]
689    fn shell_hint_stays_within_its_budget() {
690        // The skeleton budget grants the Windows shell hint exactly
691        // STATIC_INSTRUCTION_SHELL_HINT_TOKENS — keep the hint inside it.
692        let hint = build_shell_hint();
693        let tokens = count_tokens(&hint);
694        assert!(
695            tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
696            "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
697        );
698    }
699
700    #[test]
701    fn minimal_overhead_instructions_stay_within_budget() {
702        // #361 faithful arm: with LEAN_CTX_MINIMAL no session/knowledge blocks
703        // ride, so the per-turn instruction prefix must stay within the static
704        // skeleton budget plus a small margin. Guards the "~3K tok/turn" critique
705        // from regressing via dynamic-block creep.
706        const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
707            STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
708        let _iso = crate::core::data_dir::isolated_data_dir();
709        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
710        let out = build_instructions(CrpMode::Compact);
711        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
712        let tokens = count_tokens(&out);
713        assert!(
714            tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
715            "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
716        );
717    }
718
719    #[test]
720    fn static_skeleton_stays_within_budget() {
721        // #579: the static instruction skeleton (no session/knowledge blocks)
722        // rides in EVERY session of EVERY install. Detail documentation
723        // belongs in LEAN-CTX.md on disk — this assert stops silent creep.
724        // Isolated data dir = default config, like a fresh install (the dev
725        // machine's compression_level/profile must not leak into the budget).
726        let _iso = crate::core::data_dir::isolated_data_dir();
727        for (mode, base_budget) in [
728            (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
729            (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
730            (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
731        ] {
732            let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
733            let out = build_instructions_for_test(mode);
734            let tokens = count_tokens(&out);
735            assert!(
736                tokens <= budget,
737                "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
738            );
739        }
740    }
741}