Skip to main content

lean_ctx/
instructions.rs

1use crate::core::config::CompressionLevel;
2use crate::core::rules_canonical::{self as rc, Wrapper};
3use crate::tools::CrpMode;
4
5/// Universal instruction cap for all MCP clients (in tokens, not bytes).
6const INSTRUCTION_CAP_TOKENS: usize = 800;
7
8/// Token budget for the static instruction skeleton (no session/knowledge
9/// state).  Asserted in CI so instruction creep cannot silently tax every
10/// session. Measured at compression `Off` (the test pins `LEAN_CTX_COMPRESSION=off`)
11/// so the budget is deterministic across dev machines, not just clean CI (#498).
12/// Raised in reviewed steps: 520→540 / 600→640 for the sharpened ctx_* redirects
13/// (#1030), then 540→590 / 640→680 (#609 loop one-liner), then 590→615 / 680→712
14/// (proactive `RECOVER` line). Lowered 615→545 / 712→675 by the v5 rules diet
15/// (#578): the COMPACT skeleton folded loop/paradox into INTENT (measured ~505 /
16/// ~639 + headroom). Clients whose rule file already carries the canonical block
17/// get a one-line anchor instead of the skeleton (`client_loads_rules_from_file`)
18/// and land far below even this.
19#[cfg(test)]
20const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 545;
21#[cfg(test)]
22const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 675;
23/// Windows carries a one-line SHELL hint inside the skeleton.
24#[cfg(all(test, windows))]
25const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 25;
26#[cfg(all(test, not(windows)))]
27const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 0;
28
29#[must_use]
30pub fn build_instructions(crp_mode: CrpMode) -> String {
31    build_instructions_with_client(crp_mode, "")
32}
33
34#[must_use]
35pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
36    let cfg = crate::core::config::Config::load();
37    let minimal = cfg.minimal_overhead_effective_for_client(client_name);
38    let shadow = cfg.shadow_mode;
39    // Cross-channel dedup: if the client auto-loads compression from its own rule
40    // file, skip it here to avoid duplicate billing.
41    let level = if client_loads_compression_from_file(client_name) {
42        CompressionLevel::Off
43    } else {
44        CompressionLevel::effective(&cfg)
45    };
46    // persona-spec-v1 — non-coding personas carry their domain block
47    // (intent vocabulary + defaults) into the session instructions. Empty for
48    // the `coding` default, so the skeleton stays byte-identical (#498).
49    let persona_block = crate::core::persona::Persona::resolve(&cfg).prompt_block();
50    build_full_instructions(
51        crp_mode,
52        client_name,
53        minimal,
54        level,
55        shadow,
56        &persona_block,
57    )
58}
59
60/// Deterministic STATIC Claude Code instructions for the char-budget test: the
61/// cold first-contact handshake surface (skeleton + shell hint + decoder +
62/// CLAUDE.md pointer + guidance). It pins `minimal=true` (the dynamic
63/// session/knowledge/gotcha payload is governed by `INSTRUCTION_CAP_TOKENS`, not
64/// the char budget) plus `level=Off`, `shadow=false` (the default template) and
65/// an empty persona block (the `coding` default), so the result is independent
66/// of the developer's local lean-ctx config and the assertion stays
67/// deterministic (#498) for every contributor, not just clean CI.
68#[must_use]
69pub fn claude_code_static_instructions_for_test() -> String {
70    build_full_instructions(CrpMode::Off, "", true, CompressionLevel::Off, false, "")
71}
72
73/// Deterministic variant for tests (no session/knowledge state).
74#[must_use]
75pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
76    let shadow = false;
77    // Resolve the effective compression level from config/env (matches the live
78    // build_full_instructions path) so terse/compression env vars are honoured.
79    let level = CompressionLevel::effective(&crate::core::config::Config::load());
80    let skeleton = rc::render(shadow, Wrapper::Bare, level);
81    let shell_hint = build_shell_hint();
82
83    let base = format!(
84        "{skeleton}\n\
85        {shell_hint}\n\
86        {decoder_block}\n\
87        {origin}",
88        decoder_block =
89            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
90        origin = crate::core::integrity::origin_line(),
91    );
92
93    match crp_mode_suffix(crp_mode) {
94        "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
95        crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
96    }
97}
98
99/// Deterministic instruction builder for the Instruction Compiler.
100/// Uses shadow mode (COMPACT_SHADOW profile) to avoid duplicating
101/// BULLETS/NEVER/CRITICAL that the CLAUDE.md / dedicated rule file carries.
102#[must_use]
103pub fn build_instructions_with_client_for_compiler(
104    crp_mode: CrpMode,
105    client_name: &str,
106    _unified_tool_mode: bool,
107) -> String {
108    let skeleton = rc::render(true, Wrapper::Bare, CompressionLevel::Off);
109    let shell_hint = build_shell_hint();
110
111    let base = format!(
112        "{skeleton}\n\
113        {shell_hint}\n\
114        {decoder_block}\n\
115        {origin}",
116        decoder_block =
117            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
118        origin = crate::core::integrity::origin_line(),
119    );
120
121    let _ = client_name;
122
123    match crp_mode_suffix(crp_mode) {
124        "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
125        crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
126    }
127}
128
129/// LITM calibration manifest rotation (#539).
130fn rotate_wakeup_manifest(session: &crate::core::session::SessionState, profile_name: &str) {
131    use crate::core::litm_calibration::{Position, record_outcome};
132    use crate::core::session::ManifestEntry;
133
134    let mut updated = session.clone();
135
136    for entry in &updated.wakeup_manifest {
137        if !entry.missed
138            && let Some(pos) = Position::parse(&entry.position)
139        {
140            record_outcome(&entry.profile, pos, true);
141        }
142    }
143
144    let mut manifest: Vec<ManifestEntry> = Vec::new();
145    let mut push = |key: &str, position: &str| {
146        let key = key.trim();
147        if !key.is_empty() {
148            manifest.push(ManifestEntry {
149                key: key.chars().take(80).collect(),
150                position: position.to_string(),
151                profile: profile_name.to_string(),
152                missed: false,
153            });
154        }
155    };
156
157    if let Some(ref task) = updated.task {
158        push(&task.description, "begin");
159    }
160    for d in updated.decisions.iter().rev().take(5) {
161        push(&d.summary, "begin");
162    }
163    for f in updated.findings.iter().rev().take(8) {
164        push(&f.summary, "end");
165    }
166    for n in updated.next_steps.iter().take(3) {
167        push(n, "end");
168    }
169
170    updated.wakeup_manifest = manifest;
171    let _ = updated.save();
172}
173
174/// Display path for the Claude config directory (respected by CLAUDE_CONFIG_DIR).
175#[must_use]
176pub fn claude_config_dir_display() -> String {
177    match std::env::var("CLAUDE_CONFIG_DIR") {
178        Ok(dir) if !dir.trim().is_empty() => {
179            let dir = dir.trim().to_string();
180            if dir.starts_with('~') {
181                dir
182            } else if let Some(home) = dirs::home_dir() {
183                let home_str = home.to_string_lossy();
184                if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
185                    format!("~{rest}")
186                } else {
187                    dir
188                }
189            } else {
190                dir
191            }
192        }
193        _ => "~/.claude".to_string(),
194    }
195}
196
197// ── MCP per-session instructions builder ──────────────────────
198
199fn build_full_instructions(
200    crp_mode: CrpMode,
201    client_name: &str,
202    minimal: bool,
203    level: CompressionLevel,
204    shadow: bool,
205    persona_block: &str,
206) -> String {
207    let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
208    let loaded_session = if minimal {
209        None
210    } else {
211        crate::core::session::SessionState::load_latest()
212    };
213
214    let (session_block, litm_end_block) = match loaded_session {
215        Some(ref session) => {
216            rotate_wakeup_manifest(session, profile.name);
217            let share = crate::core::litm_calibration::begin_share(profile.name);
218            let mut positioned = crate::core::litm::position_optimize_with_share(session, share);
219            // #962: hard token ceiling so the re-injected ACTIVE SESSION block can
220            // never crowd out the user's task (deterministic, generous default).
221            positioned.enforce_token_budget(crate::core::litm::active_session_budget());
222            let begin = format!(
223                "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
224                profile.name, positioned.begin_block
225            );
226            let end = if positioned.end_block.is_empty() {
227                String::new()
228            } else {
229                format!(
230                    "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
231                    positioned.end_block
232                )
233            };
234            (begin, end)
235        }
236        None => (String::new(), String::new()),
237    };
238
239    let project_root_for_blocks = if minimal {
240        None
241    } else {
242        loaded_session
243            .as_ref()
244            .and_then(|s| s.project_root.clone())
245            .or_else(|| {
246                std::env::current_dir()
247                    .ok()
248                    .map(|p| p.to_string_lossy().to_string())
249            })
250    };
251
252    let knowledge_block = match &project_root_for_blocks {
253        Some(root) => {
254            let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
255            match knowledge {
256                Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
257                    let aaak = k.format_aaak();
258                    if aaak.is_empty() {
259                        String::new()
260                    } else {
261                        format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
262                    }
263                }
264                _ => String::new(),
265            }
266        }
267        None => String::new(),
268    };
269
270    let gotcha_block = match &project_root_for_blocks {
271        Some(root) => {
272            let store = crate::core::gotcha_tracker::GotchaStore::load(root);
273            let files: Vec<String> = loaded_session
274                .as_ref()
275                .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
276                .unwrap_or_default();
277            let block = store.format_injection_block(&files);
278            if block.is_empty() {
279                String::new()
280            } else {
281                format!("\n{block}\n")
282            }
283        }
284        None => String::new(),
285    };
286
287    let health_block = match &project_root_for_blocks {
288        Some(root) => {
289            let block = crate::core::code_health::persist::format_session_block(root);
290            if block.is_empty() {
291                String::new()
292            } else {
293                format!("\n{block}\n")
294            }
295        }
296        None => String::new(),
297    };
298
299    let shell_hint = build_shell_hint();
300
301    // Skeleton includes tool-mapping rules + compression prompt (if level active).
302    // Shadow mode omits BULLETS/NEVER/CRITICAL automatically.
303    //
304    // Cross-channel dedup (#578): when the client's own auto-loaded rule file
305    // already carries the canonical rules block (Cursor mdc, Codex
306    // instructions.md), repeating the skeleton here would bill the same
307    // guidance twice on every session. A one-line anchor keeps the binding;
308    // the compression payload is deduped separately via `level` above.
309    //
310    // Hook-covered hosts (GL #1153) get the hook-aware anchor: repeating
311    // "ctx_* replaces native tools" to a Cursor whose hooks already compress
312    // the native calls re-creates exactly the instruction dissonance the
313    // HookCovered rule profile removes.
314    let skeleton = if client_loads_rules_from_file(client_name) {
315        let anchor = if client_is_hook_covered(client_name) {
316            HOOK_COVERED_ANCHOR
317        } else {
318            SKELETON_ANCHOR
319        };
320        let compression = rc::compression_text(level);
321        if compression.is_empty() {
322            anchor.to_string()
323        } else {
324            format!("{anchor}\n{compression}")
325        }
326    } else {
327        rc::render(shadow, Wrapper::Bare, level)
328    };
329
330    // Pointer to the full rule file (honours CLAUDE_CONFIG_DIR): agents load the
331    // detailed instructions on demand from there instead of inlining them.
332    let config_dir = claude_config_dir_display();
333
334    // Persona domain block (persona-spec-v1): placed right after the skeleton
335    // so the vocabulary frames everything that follows. Empty for `coding`.
336    let persona_section = if persona_block.is_empty() {
337        String::new()
338    } else {
339        format!("\n{persona_block}")
340    };
341
342    let base = format!(
343        "{skeleton}\n\
344        {persona_section}\
345        {shell_hint}\n\
346        {decoder_block}\n\
347        Full instructions at {config_dir}/CLAUDE.md\n\
348        {session_block}\n\
349        {knowledge_block}\n\
350        {gotcha_block}\n\
351        {health_block}\n\
352        {origin}\n\
353        {litm_end_block}",
354        decoder_block =
355            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
356        origin = crate::core::integrity::origin_line(),
357        litm_end_block = litm_end_block
358    );
359
360    // Guidance suffix: CRP mode + general output rule.
361    // This is the operational contract — protected from truncation.
362    let guidance_suffix = match crp_mode_suffix(crp_mode) {
363        "" => rc::INTELLIGENCE.to_string(),
364        crp => format!("{crp}\n\n{}", rc::INTELLIGENCE),
365    };
366
367    assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
368}
369
370fn crp_mode_suffix(crp_mode: CrpMode) -> &'static str {
371    match crp_mode {
372        CrpMode::Off => "",
373        CrpMode::Compact => {
374            "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
375             diff lines (+/-) only; <=200 tok; trust tool outputs."
376        }
377        CrpMode::Tdd => {
378            "CRP MODE: tdd — max density; Fn refs + diff lines only \
379             (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
380        }
381    }
382}
383
384fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
385    use crate::core::tokens::count_tokens;
386    let suffix = suffix.trim_end_matches('\n');
387    if suffix.is_empty() {
388        let full = base.to_string();
389        return if count_tokens(&full) > cap_tokens {
390            truncate_to_token_cap(&full, cap_tokens)
391        } else {
392            full
393        };
394    }
395
396    let full = format!("{base}\n\n{suffix}");
397    if count_tokens(&full) <= cap_tokens {
398        return full;
399    }
400
401    let suffix_tokens = count_tokens(suffix);
402    let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
403        return truncate_to_token_cap(&full, cap_tokens);
404    };
405    let trimmed_base = truncate_to_token_cap(base, base_budget);
406    format!("{trimmed_base}\n\n{suffix}")
407}
408
409fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
410    use crate::core::tokens::count_tokens;
411    if count_tokens(s) <= cap_tokens {
412        return s.to_string();
413    }
414    let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
415    let (mut lo, mut hi) = (0usize, cuts.len());
416    let mut best: Option<usize> = None;
417    while lo < hi {
418        let mid = lo + (hi - lo) / 2;
419        let end = cuts[mid];
420        if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
421            best = Some(end);
422            lo = mid + 1;
423        } else {
424            hi = mid;
425        }
426    }
427    if let Some(end) = best {
428        return s[..end].to_string();
429    }
430    let byte_approx = cap_tokens * 4;
431    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
432    s[..safe].to_string()
433}
434
435/// Backward-compat alias kept for external callers.
436#[must_use]
437pub fn claude_code_instructions() -> String {
438    build_instructions(CrpMode::Off)
439}
440
441/// One-line replacement for the full rules skeleton when the client already
442/// auto-loads the canonical block from its own rule file (#578). Anchors the
443/// binding ("the rules you loaded apply to THIS server") without re-billing
444/// the guidance.
445const SKELETON_ANCHOR: &str = "lean-ctx active — your auto-loaded lean-ctx rules apply: \
446    ctx_* tools replace native Read/Grep/Shell/Glob (ctx_compose first).";
447
448/// The anchor for hook-covered hosts (GL #1153): consistent with the
449/// HookCovered rule profile — native tools are fine (the hooks compress
450/// them), the MCP call is for the exclusive capabilities.
451const HOOK_COVERED_ANCHOR: &str = "lean-ctx active — hooks compress native Shell/Read/Grep \
452    transparently; call ctx_compose to orient, ctx_semantic_search / ctx_knowledge for \
453    meaning & memory.";
454
455fn client_loads_compression_from_file(client_name: &str) -> bool {
456    crate::core::home::resolve_home_dir().is_some_and(|home| {
457        crate::core::rules_channel::client_autoloads_compression(client_name, &home)
458    })
459}
460
461fn client_loads_rules_from_file(client_name: &str) -> bool {
462    crate::core::home::resolve_home_dir()
463        .is_some_and(|home| crate::core::rules_channel::client_autoloads_rules(client_name, &home))
464}
465
466fn client_is_hook_covered(client_name: &str) -> bool {
467    crate::core::home::resolve_home_dir()
468        .is_some_and(|home| crate::core::rules_channel::client_hook_covered(client_name, &home))
469}
470
471fn build_shell_hint() -> String {
472    if !cfg!(windows) {
473        return String::new();
474    }
475    let name = crate::shell::shell_name();
476    let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
477    if is_posix {
478        format!("\nSHELL: {name} (POSIX) — no PowerShell cmdlets.\n")
479    } else if name.contains("powershell") || name.contains("pwsh") {
480        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
481    } else {
482        format!("\nSHELL: {name}.\n")
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::core::tokens::count_tokens;
490
491    #[test]
492    fn guidance_suffix_survives_oversized_base() {
493        let base = "SESSION LINE\n".repeat(4000);
494        let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
495        let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
496        assert!(out.contains("OUTPUT STYLE: expert-terse"));
497        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
498        assert!(out.len() < base.len());
499    }
500
501    #[test]
502    fn empty_client_never_dedups_compression() {
503        assert!(!client_loads_compression_from_file(""));
504        assert!(!client_loads_compression_from_file("totally-unknown-agent"));
505    }
506
507    #[test]
508    fn covered_client_gets_anchor_instead_of_skeleton() {
509        // #578: a client whose rule file carries the canonical block must not
510        // pay for the full skeleton again in every MCP session.
511        let _guard = crate::core::data_dir::test_env_lock();
512        let tmp = tempfile::tempdir().unwrap();
513        let home = tmp.path();
514        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
515        std::fs::write(
516            home.join(".cursor/rules/lean-ctx.mdc"),
517            rc::render(
518                false,
519                Wrapper::Dedicated,
520                crate::core::config::CompressionLevel::Standard,
521            ),
522        )
523        .unwrap();
524        let old_home = std::env::var("HOME").ok();
525        crate::test_env::set_var("HOME", home);
526        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
527
528        let covered = build_instructions_with_client(CrpMode::Off, "cursor");
529        let uncovered = build_instructions_with_client(CrpMode::Off, "some-other-agent");
530
531        if let Some(h) = old_home {
532            crate::test_env::set_var("HOME", h);
533        } else {
534            crate::test_env::remove_var("HOME");
535        }
536        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
537
538        assert!(
539            covered.contains(SKELETON_ANCHOR),
540            "covered client must get the anchor:\n{covered}"
541        );
542        assert!(
543            !covered.contains("MANDATORY MAPPING"),
544            "covered client must not re-pay the skeleton:\n{covered}"
545        );
546        // The mdc also carries the compression block → level dedups to Off.
547        assert!(
548            !covered.contains("OUTPUT STYLE:"),
549            "covered client must not re-pay the compression prompt:\n{covered}"
550        );
551        assert!(
552            uncovered.contains("MANDATORY MAPPING"),
553            "uncovered client keeps the full skeleton:\n{uncovered}"
554        );
555        eprintln!(
556            "instructions footprint: covered={} tok, uncovered={} tok",
557            count_tokens(&covered),
558            count_tokens(&uncovered)
559        );
560        assert!(
561            count_tokens(&covered) < count_tokens(&uncovered),
562            "anchor path must be strictly cheaper"
563        );
564    }
565
566    #[test]
567    fn hook_covered_client_gets_hook_aware_anchor() {
568        // GL #1153: with lean-ctx hooks covering the native tools, the anchor
569        // must not repeat "ctx_* replaces native tools" — that is exactly the
570        // instruction dissonance the HookCovered profile removes.
571        let _guard = crate::core::data_dir::test_env_lock();
572        let tmp = tempfile::tempdir().unwrap();
573        let home = tmp.path();
574        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
575        std::fs::write(
576            home.join(".cursor/rules/lean-ctx.mdc"),
577            rc::render(
578                false,
579                Wrapper::HookCovered,
580                crate::core::config::CompressionLevel::Off,
581            ),
582        )
583        .unwrap();
584        std::fs::write(
585            home.join(".cursor/hooks.json"),
586            r#"{"version":1,"hooks":{"preToolUse":[
587                {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
588                {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
589            ]}}"#,
590        )
591        .unwrap();
592        let old_home = std::env::var("HOME").ok();
593        crate::test_env::set_var("HOME", home);
594        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
595
596        let covered = build_instructions_with_client(CrpMode::Off, "cursor");
597
598        if let Some(h) = old_home {
599            crate::test_env::set_var("HOME", h);
600        } else {
601            crate::test_env::remove_var("HOME");
602        }
603        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
604
605        assert!(
606            covered.contains(HOOK_COVERED_ANCHOR),
607            "hook-covered client must get the hook-aware anchor:\n{covered}"
608        );
609        assert!(
610            !covered.contains(SKELETON_ANCHOR) && !covered.contains("MANDATORY MAPPING"),
611            "hook-covered client must not carry the replace-native wording:\n{covered}"
612        );
613    }
614
615    #[test]
616    fn non_coding_persona_block_lands_in_instructions() {
617        let _guard = crate::core::data_dir::test_env_lock();
618        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
619
620        crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
621        let research = build_instructions_with_client(CrpMode::Off, "");
622
623        crate::test_env::set_var("LEAN_CTX_PERSONA", "coding");
624        let coding = build_instructions_with_client(CrpMode::Off, "");
625
626        crate::test_env::remove_var("LEAN_CTX_PERSONA");
627        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
628
629        assert!(
630            research.contains("PERSONA: research"),
631            "research persona must announce its domain block:\n{research}"
632        );
633        assert!(
634            research.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
635            "research persona must carry its intent vocabulary:\n{research}"
636        );
637        assert!(
638            !coding.contains("PERSONA:"),
639            "the coding default must keep the instructions byte-stable (#498):\n{coding}"
640        );
641    }
642
643    #[test]
644    fn under_cap_keeps_everything() {
645        let base = "tool mapping block";
646        let suffix = "OUTPUT STYLE: dense";
647        let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
648        assert!(out.contains(base));
649        assert!(out.contains(suffix));
650    }
651
652    #[test]
653    fn empty_suffix_caps_base_only() {
654        let base = "x\n".repeat(4000);
655        let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
656        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
657    }
658
659    #[cfg(windows)]
660    #[test]
661    fn shell_hint_stays_within_its_budget() {
662        let hint = build_shell_hint();
663        let tokens = count_tokens(&hint);
664        assert!(
665            tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
666            "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
667        );
668    }
669
670    #[test]
671    fn minimal_overhead_instructions_stay_within_budget() {
672        const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
673            STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
674        let _iso = crate::core::data_dir::isolated_data_dir();
675        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
676        let out = build_instructions(CrpMode::Compact);
677        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
678        let tokens = count_tokens(&out);
679        assert!(
680            tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
681            "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
682        );
683    }
684
685    #[test]
686    fn static_skeleton_stays_within_budget() {
687        let _iso = crate::core::data_dir::isolated_data_dir();
688        // Pin compression Off so the measured skeleton — and thus this budget —
689        // is deterministic regardless of the dev's local compression_level (#498).
690        crate::test_env::set_var("LEAN_CTX_COMPRESSION", "off");
691        for (mode, base_budget) in [
692            (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
693            (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
694            (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
695        ] {
696            let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
697            let out = build_instructions_for_test(mode);
698            let tokens = count_tokens(&out);
699            assert!(
700                tokens <= budget,
701                "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
702            );
703        }
704        crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
705    }
706}