Skip to main content

lean_ctx/
instructions.rs

1use crate::core::config::CompressionLevel;
2use crate::core::protocol::CrpMode;
3use crate::core::rules_canonical::{self as rc, Wrapper};
4
5pub mod solution;
6pub use solution::solution_ladder_text;
7
8/// Universal instruction cap for all MCP clients (in tokens, not bytes).
9const INSTRUCTION_CAP_TOKENS: usize = 800;
10
11/// Token budget for the static instruction skeleton (no session/knowledge
12/// state).  Asserted in CI so instruction creep cannot silently tax every
13/// session. Measured at compression `Off` (the test pins `LEAN_CTX_COMPRESSION=off`)
14/// so the budget is deterministic across dev machines, not just clean CI (#498).
15/// Raised in reviewed steps: 520→540 / 600→640 for the sharpened ctx_* redirects
16/// (#1030), then 540→590 / 640→680 (#609 loop one-liner), then 590→615 / 680→712
17/// (proactive `RECOVER` line). Lowered 615→545 / 712→675 by the v5 rules diet
18/// (#578): the COMPACT skeleton folded loop/paradox into INTENT (measured ~505 /
19/// ~639 + headroom). Clients whose rule file already carries the canonical block
20/// get a one-line anchor instead of the skeleton (`client_loads_rules_from_file`)
21/// and land far below even this.
22#[cfg(test)]
23const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 665;
24#[cfg(test)]
25const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 795;
26/// Windows carries a one-line SHELL hint inside the skeleton.
27#[cfg(all(test, windows))]
28const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 25;
29#[cfg(all(test, not(windows)))]
30const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 0;
31
32#[must_use]
33pub fn build_instructions(crp_mode: CrpMode) -> String {
34    build_instructions_with_client(crp_mode, "")
35}
36
37#[must_use]
38pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
39    build_instructions_with_client_and_optional_session(crp_mode, client_name, None)
40}
41
42/// Builds MCP instructions using state owned by the current server instance.
43///
44/// Callers that do not own a live session deliberately get static instructions:
45/// loading a persisted "latest" session here would make one MCP process inject
46/// another process's state.
47#[must_use]
48pub fn build_instructions_with_client_and_session(
49    crp_mode: CrpMode,
50    client_name: &str,
51    session: &crate::core::session::SessionState,
52) -> String {
53    build_instructions_with_client_and_optional_session(crp_mode, client_name, Some(session))
54}
55
56/// The client's documented hard limit for initialize `instructions`, in chars
57/// (`client_constraints`; Claude Code: 2048). Claude Code cuts any overflow
58/// itself, mid-sentence, with a `[truncated]` marker.
59fn client_instruction_char_cap(client_name: &str) -> Option<usize> {
60    let id = crate::core::client_capabilities::ClientMcpCapabilities::detect(client_name).client_id;
61    crate::core::client_constraints::by_client_id(&id).and_then(|c| c.mcp_instructions_max_chars)
62}
63
64/// Longest prefix of `s` that ends at a line break and holds at most `budget`
65/// characters (not bytes); CRLF-safe. The skeleton leads with the binding
66/// mapping, so trimming from the end drops the least important lines first.
67/// Deterministic (#498).
68fn trim_to_char_budget(s: &str, budget: usize) -> String {
69    if s.chars().count() <= budget {
70        return s.to_string();
71    }
72    // `n` = chars before position `i`; a `\n` there ends a prefix of `n` chars.
73    let best = s
74        .char_indices()
75        .enumerate()
76        .take_while(|(n, _)| *n <= budget)
77        .filter(|(_, (_, c))| *c == '\n')
78        .map(|(_, (i, _))| i)
79        .last()
80        .unwrap_or(0);
81    if best == 0 {
82        return s.chars().take(budget).collect();
83    }
84    s[..best].trim_end().to_string()
85}
86
87fn build_instructions_with_client_and_optional_session(
88    crp_mode: CrpMode,
89    client_name: &str,
90    session: Option<&crate::core::session::SessionState>,
91) -> String {
92    let cfg = crate::core::config::Config::load();
93    let minimal = cfg.minimal_overhead_effective_for_client(client_name);
94    let shadow = cfg.shadow_mode;
95    // Cross-channel dedup: if the client auto-loads compression from its own rule
96    // file, skip it here to avoid duplicate billing.
97    let level = if client_loads_compression_from_file(client_name) {
98        CompressionLevel::Off
99    } else {
100        CompressionLevel::effective(&cfg)
101    };
102    // persona-spec-v1 — non-coding personas carry their domain block
103    // (intent vocabulary + defaults) into the session instructions. Empty for
104    // the `coding` default, so the skeleton stays byte-identical (#498).
105    let persona_block = crate::core::persona::Persona::resolve(&cfg).prompt_block();
106    build_full_instructions(
107        crp_mode,
108        client_name,
109        minimal,
110        level,
111        shadow,
112        &persona_block,
113        session,
114    )
115}
116
117/// Deterministic STATIC Claude Code instructions for the char-budget test: the
118/// cold first-contact handshake surface (skeleton + shell hint + decoder +
119/// CLAUDE.md pointer + guidance). It pins `minimal=true` (the dynamic
120/// session/knowledge/gotcha payload is governed by `INSTRUCTION_CAP_TOKENS`, not
121/// the char budget) plus `level=Off`, `shadow=false` (the default template) and
122/// an empty persona block (the `coding` default), so the result is independent
123/// of the developer's local lean-ctx config and the assertion stays
124/// deterministic (#498) for every contributor, not just clean CI.
125#[must_use]
126pub fn claude_code_static_instructions_for_test() -> String {
127    build_full_instructions(
128        CrpMode::Off,
129        "",
130        true,
131        CompressionLevel::Off,
132        false,
133        "",
134        None,
135    )
136}
137
138/// Deterministic variant for tests (no session/knowledge state).
139#[must_use]
140pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
141    let shadow = false;
142    // Resolve the effective compression level from config/env (matches the live
143    // build_full_instructions path) so terse/compression env vars are honoured.
144    let level = CompressionLevel::effective(&crate::core::config::Config::load());
145    let tp =
146        crate::core::tool_profiles::ToolProfile::from_config(&crate::core::config::Config::load());
147    let skeleton = rc::render(shadow, Wrapper::Bare, level, &tp);
148    let shell_hint = build_shell_hint();
149
150    let base = format!(
151        "{skeleton}\n\
152        {shell_hint}\n\
153        {decoder_block}\n\
154        {origin}",
155        decoder_block =
156            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
157        origin = crate::core::integrity::origin_line(),
158    );
159
160    match crp_mode_suffix(crp_mode) {
161        "" => base,
162        crp => format!("{base}\n\n{crp}"),
163    }
164}
165
166/// Deterministic instruction builder for the Instruction Compiler.
167/// Uses shadow mode (COMPACT_SHADOW profile) to avoid duplicating
168/// BULLETS/NEVER/CRITICAL that the CLAUDE.md / dedicated rule file carries.
169#[must_use]
170pub fn build_instructions_with_client_for_compiler(
171    crp_mode: CrpMode,
172    client_name: &str,
173    _unified_tool_mode: bool,
174) -> String {
175    let tp = crate::core::tool_visibility::guidance_profile(
176        &crate::core::config::Config::load(),
177        client_name,
178    );
179    let skeleton = rc::render(true, Wrapper::Bare, CompressionLevel::Off, &tp);
180    let shell_hint = build_shell_hint();
181
182    let base = format!(
183        "{skeleton}\n\
184        {shell_hint}\n\
185        {decoder_block}\n\
186        {origin}",
187        decoder_block =
188            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
189        origin = crate::core::integrity::origin_line(),
190    );
191
192    match crp_mode_suffix(crp_mode) {
193        "" => base,
194        crp => format!("{base}\n\n{crp}"),
195    }
196}
197
198/// Display path for the Claude config directory (respected by CLAUDE_CONFIG_DIR).
199#[must_use]
200pub fn claude_config_dir_display() -> String {
201    match std::env::var("CLAUDE_CONFIG_DIR") {
202        Ok(dir) if !dir.trim().is_empty() => {
203            let dir = dir.trim().to_string();
204            if dir.starts_with('~') {
205                dir
206            } else if let Some(home) = dirs::home_dir() {
207                let home_str = home.to_string_lossy();
208                if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
209                    format!("~{rest}")
210                } else {
211                    dir
212                }
213            } else {
214                dir
215            }
216        }
217        _ => "~/.claude".to_string(),
218    }
219}
220
221// ── MCP per-session instructions builder ──────────────────────
222
223fn build_full_instructions(
224    crp_mode: CrpMode,
225    client_name: &str,
226    minimal: bool,
227    level: CompressionLevel,
228    shadow: bool,
229    persona_block: &str,
230    session: Option<&crate::core::session::SessionState>,
231) -> String {
232    let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
233    let loaded_session = (!minimal).then_some(session).flatten();
234
235    let (session_block, litm_end_block) = match loaded_session {
236        Some(ref session) => {
237            let mut positioned = crate::core::litm::position_optimize(session);
238            // #962: hard token ceiling so the re-injected ACTIVE SESSION block can
239            // never crowd out the user's task (deterministic, generous default).
240            positioned.enforce_token_budget(crate::core::litm::active_session_budget());
241            let begin = format!(
242                "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
243                profile.name, positioned.begin_block
244            );
245            let end = if positioned.end_block.is_empty() {
246                String::new()
247            } else {
248                format!(
249                    "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
250                    positioned.end_block
251                )
252            };
253            (begin, end)
254        }
255        None => (String::new(), String::new()),
256    };
257
258    let project_root_for_blocks = if minimal {
259        None
260    } else {
261        loaded_session
262            .as_ref()
263            .and_then(|s| s.project_root.clone())
264            .or_else(|| {
265                std::env::current_dir()
266                    .ok()
267                    .map(|p| p.to_string_lossy().to_string())
268            })
269    };
270
271    let knowledge_block = match &project_root_for_blocks {
272        Some(root) => {
273            let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
274            match knowledge {
275                Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
276                    // Always the whole memory. These instructions are the
277                    // standing text of one connection, not a message added
278                    // to a history: a context that receives them holds no
279                    // earlier copy, so an "unchanged since <hash>" stub
280                    // (`format_aaak_delta`) would stand for text it never
281                    // got. Nothing is remembered as delivered here.
282                    let memory = k.format_aaak();
283                    if memory.is_empty() {
284                        String::new()
285                    } else {
286                        format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", memory.trim())
287                    }
288                }
289                _ => String::new(),
290            }
291        }
292        None => String::new(),
293    };
294
295    let gotcha_block = match &project_root_for_blocks {
296        Some(root) => {
297            let store = crate::core::gotcha_tracker::GotchaStore::load(root);
298            let files: Vec<String> = loaded_session
299                .as_ref()
300                .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
301                .unwrap_or_default();
302            let block = store.format_injection_block(&files);
303            if block.is_empty() {
304                String::new()
305            } else {
306                format!("\n{block}\n")
307            }
308        }
309        None => String::new(),
310    };
311
312    let health_block = match &project_root_for_blocks {
313        Some(root) => {
314            let block = crate::core::code_health::persist::format_session_block(root);
315            if block.is_empty() {
316                String::new()
317            } else {
318                format!("\n{block}\n")
319            }
320        }
321        None => String::new(),
322    };
323
324    let shell_hint = build_shell_hint();
325
326    // Skeleton includes tool-mapping rules + compression prompt (if level active).
327    // Shadow mode omits BULLETS/NEVER/CRITICAL automatically.
328    //
329    // Cross-channel dedup (#578): when the client's own auto-loaded rule file
330    // already carries the canonical rules block (Cursor mdc, Codex
331    // instructions.md), repeating the skeleton here would bill the same
332    // guidance twice on every session. A one-line anchor keeps the binding;
333    // the compression payload is deduped separately via `level` above.
334    //
335    // Hook-covered hosts (GL #1153) get the hook-aware anchor: repeating
336    // "ctx_* replaces native tools" to a Cursor whose hooks already compress
337    // the native calls re-creates exactly the instruction dissonance the
338    // HookCovered rule profile removes.
339    let cfg = crate::core::config::Config::load();
340    // #1849: name only tools this session can call — the same gates as
341    // `tools/list` (profile, `disabled_tools`, client quirks), not the bare
342    // configured profile.
343    let tool_profile = crate::core::tool_visibility::guidance_profile(&cfg, client_name);
344    let is_shadow_only = is_shadow_surface_active();
345    let skeleton = if client_loads_rules_from_file(client_name) {
346        let anchor = if client_is_hook_covered(client_name) {
347            if is_shadow_only {
348                shadow_only_anchor(&tool_profile)
349            } else {
350                hook_covered_anchor(&tool_profile)
351            }
352        } else {
353            skeleton_anchor(&tool_profile)
354        };
355        let compression = rc::compression_text(level);
356        if compression.is_empty() {
357            anchor
358        } else {
359            format!("{anchor}\n{compression}")
360        }
361    } else {
362        // #987: shadow claim must match reality. `shadow_mode=true` (the
363        // config default) only means the *config* prefers shadow — it does NOT
364        // prove interception hooks are actually installed for this client.
365        // Emitting "native calls auto-route" when the install denies native
366        // tools (replace mode) wastes a round-trip when the agent trusts it.
367        // Gate on hook coverage: only clients with installed hooks get the
368        // shadow instruction.
369        let effective_shadow = shadow && client_is_hook_covered(client_name);
370        rc::render(effective_shadow, Wrapper::Bare, level, &tool_profile)
371    };
372
373    // Pointer to the full rule file (honours CLAUDE_CONFIG_DIR): agents load the
374    // detailed instructions on demand from there instead of inlining them.
375    let config_dir = claude_config_dir_display();
376
377    // Persona domain block (persona-spec-v1): placed right after the skeleton
378    // so the vocabulary frames everything that follows. Empty for `coding`.
379    let persona_section = if persona_block.is_empty() {
380        String::new()
381    } else {
382        format!("\n{persona_block}")
383    };
384
385    // Solution Intelligence: inject the efficiency ladder when enabled
386    let solution_ladder = {
387        let loaded_config = crate::core::config::Config::load();
388        let sol_cfg = &loaded_config.solution;
389        let is_subagent = crate::core::conversation::is_subagent_context();
390        let inject_solution = if is_subagent {
391            sol_cfg.inject_in_subagents
392        } else {
393            sol_cfg.inject_in_instructions
394        };
395        if sol_cfg.enabled && inject_solution {
396            let ladder =
397                crate::instructions::solution::solution_ladder_text(sol_cfg.effective_intensity());
398            if ladder.is_empty() {
399                String::new()
400            } else {
401                format!("\n{ladder}\n")
402            }
403        } else {
404            String::new()
405        }
406    };
407
408    let base = format!(
409        "{skeleton}\n\
410        {persona_section}\
411        {shell_hint}\n\
412        {decoder_block}\n\
413        Full instructions at {config_dir}/CLAUDE.md\n\
414        {session_block}\n\
415        {knowledge_block}\n\
416        {gotcha_block}\n\
417        {health_block}\n\
418        {origin}\n\
419        {litm_end_block}",
420        decoder_block =
421            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
422        origin = crate::core::integrity::origin_line(),
423        litm_end_block = litm_end_block
424    );
425
426    // Guidance suffix: CRP mode followed by the Solution Intelligence ladder.
427    // Both are operational contracts, so they are protected from truncation.
428    // Keep the ladder after the CRP block.
429    let guidance_suffix = format!("{}{}", crp_mode_suffix(crp_mode), solution_ladder);
430
431    // A host char cap trims only the base: the protected suffix and the
432    // `\n\n` join always fit, so the host never cuts mid-sentence.
433    let base = match client_instruction_char_cap(client_name) {
434        Some(max) => {
435            let suffix_chars = guidance_suffix.trim_end_matches('\n').chars().count();
436            trim_to_char_budget(&base, max.saturating_sub(suffix_chars + 2))
437        }
438        None => base,
439    };
440    assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
441}
442
443fn crp_mode_suffix(crp_mode: CrpMode) -> &'static str {
444    match crp_mode {
445        CrpMode::Off => "",
446        CrpMode::Compact => {
447            "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
448             diff lines (+/-) only; <=200 tok; trust tool outputs."
449        }
450        CrpMode::Tdd => {
451            "CRP MODE: tdd — max density; Fn refs + diff lines only \
452             (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
453        }
454    }
455}
456
457fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
458    use crate::core::tokens::count_tokens;
459    let suffix = suffix.trim_end_matches('\n');
460    if suffix.is_empty() {
461        let full = base.to_string();
462        return if count_tokens(&full) > cap_tokens {
463            truncate_to_token_cap(&full, cap_tokens)
464        } else {
465            full
466        };
467    }
468
469    let full = format!("{base}\n\n{suffix}");
470    if count_tokens(&full) <= cap_tokens {
471        return full;
472    }
473
474    let suffix_tokens = count_tokens(suffix);
475    let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
476        return truncate_to_token_cap(&full, cap_tokens);
477    };
478    let trimmed_base = truncate_to_token_cap(base, base_budget);
479    format!("{trimmed_base}\n\n{suffix}")
480}
481
482fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
483    use crate::core::tokens::count_tokens;
484    if count_tokens(s) <= cap_tokens {
485        return s.to_string();
486    }
487    let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
488    let (mut lo, mut hi) = (0usize, cuts.len());
489    let mut best: Option<usize> = None;
490    while lo < hi {
491        let mid = lo + (hi - lo) / 2;
492        let end = cuts[mid];
493        if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
494            best = Some(end);
495            lo = mid + 1;
496        } else {
497            hi = mid;
498        }
499    }
500    if let Some(end) = best {
501        return s[..end].to_string();
502    }
503    let byte_approx = cap_tokens * 4;
504    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
505    s[..safe].to_string()
506}
507
508/// Backward-compat alias kept for external callers.
509#[must_use]
510pub fn claude_code_instructions() -> String {
511    build_instructions(CrpMode::Off)
512}
513
514/// One-line anchor for clients whose rule file carries the canonical block (#578).
515/// States the routing once, neutrally (A3): the rules file carries the detail,
516/// and an "orient first" push added steps on short tasks (pilot P6).
517fn skeleton_anchor(_tp: &crate::core::tool_profiles::ToolProfile) -> String {
518    SKELETON_ANCHOR.into()
519}
520
521/// Anchor for hook-covered hosts (GL #1153). Profile-aware (#756).
522fn hook_covered_anchor(tp: &crate::core::tool_profiles::ToolProfile) -> String {
523    let mut s =
524        String::from("lean-ctx active — hooks compress native Shell/Read/Grep transparently");
525    if tp.is_tool_enabled("ctx_compose") {
526        s.push_str("; call ctx_compose to orient");
527    }
528    // #509: ctx_semantic_search folded into ctx_search(action=semantic)
529    if tp.is_tool_enabled("ctx_session") || tp.is_tool_enabled("ctx_knowledge") {
530        s.push_str(", ctx_search(action=semantic) / ctx_knowledge for meaning & memory");
531    }
532    s.push('.');
533    s
534}
535
536/// Anchor for shadow-only surface: hook-covered client whose `tools/list` only
537/// advertises `ctx_call`. Native tools are compressed by hooks; exclusive
538/// features (compose, knowledge, session) are reachable via `ctx_call`.
539fn shadow_only_anchor(tp: &crate::core::tool_profiles::ToolProfile) -> String {
540    let mut s =
541        String::from("lean-ctx active — hooks compress native Shell/Read/Grep transparently");
542    let has_exclusive = tp.is_tool_enabled("ctx_compose")
543        || tp.is_tool_enabled("ctx_knowledge")
544        || tp.is_tool_enabled("ctx_session");
545    if has_exclusive {
546        s.push_str("; use ctx_call for compose/knowledge/session");
547    }
548    s.push('.');
549    s
550}
551
552/// Whether the shadow-only tool surface is currently active.
553fn is_shadow_surface_active() -> bool {
554    let cfg = crate::core::config::Config::load();
555    match cfg.tool_surface.as_deref() {
556        Some("shadow") => true,
557        Some("mcp") => false,
558        _ => cfg.shadow_mode, // "auto": follows shadow_mode default
559    }
560}
561
562const SKELETON_ANCHOR: &str = "lean-ctx active — your auto-loaded lean-ctx rules apply: \
563    ctx_* tools replace native Read/Grep/Shell/Glob.";
564
565fn client_loads_compression_from_file(client_name: &str) -> bool {
566    crate::core::home::resolve_home_dir().is_some_and(|home| {
567        crate::core::rules_channel::client_autoloads_compression(client_name, &home)
568    })
569}
570
571fn client_loads_rules_from_file(client_name: &str) -> bool {
572    crate::core::home::resolve_home_dir()
573        .is_some_and(|home| crate::core::rules_channel::client_autoloads_rules(client_name, &home))
574}
575
576fn client_is_hook_covered(client_name: &str) -> bool {
577    crate::core::home::resolve_home_dir()
578        .is_some_and(|home| crate::core::rules_channel::client_hook_covered(client_name, &home))
579}
580
581fn build_shell_hint() -> String {
582    if !cfg!(windows) {
583        return String::new();
584    }
585    shell_hint_text(&crate::shell::shell_name())
586}
587
588/// The Windows shell hint for `name`, independent of the running platform.
589///
590/// Split out for the per-turn token budget (#1651). The hint is empty on POSIX
591/// and not on Windows, so the prefix is not the same size everywhere — and a
592/// budget measured where the developer happens to sit answers a different
593/// question than the one CI enforces. The guard prices this branch on every
594/// platform, and calls the same function the runtime does so the two cannot
595/// drift apart.
596pub(crate) fn shell_hint_text(name: &str) -> String {
597    let is_posix = matches!(name, "bash" | "sh" | "zsh" | "fish");
598    if is_posix {
599        format!("\nSHELL: {name} (POSIX) — no PowerShell cmdlets.\n")
600    } else if name.contains("powershell") || name.contains("pwsh") {
601        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
602    } else {
603        format!("\nSHELL: {name}.\n")
604    }
605}
606
607/// The largest number of tokens [`build_shell_hint`] can add on Windows (#1651).
608///
609/// The candidate list is not closed — `shell_name()` returns whatever basename
610/// it finds — but it covers every branch of [`shell_hint_text`] with the longest
611/// realistic name, which is what the budget needs. An exotic long shell name
612/// would cost a token or two more; the budget carries margin for exactly that.
613#[cfg(test)]
614pub(crate) fn max_shell_hint_tokens() -> usize {
615    const BUDGETED_SHELL_NAMES: &[&str] = &[
616        "powershell",
617        "pwsh",
618        "cmd",
619        "bash",
620        "sh",
621        "zsh",
622        "fish",
623        "git-bash",
624    ];
625    BUDGETED_SHELL_NAMES
626        .iter()
627        .map(|name| crate::core::tokens::count_tokens(&shell_hint_text(name)))
628        .max()
629        .unwrap_or(0)
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::core::tokens::count_tokens;
636
637    /// The per-turn prefix a provider caches — advertised tool schemas, the
638    /// initialize instructions and the rules block — must be byte-identical for
639    /// two sessions with different state in different data dirs; any drift
640    /// re-writes the cache on every session start (Optimierungsplan P2, #498).
641    #[test]
642    fn default_prefix_is_byte_identical_across_sessions() {
643        use crate::core::session::SessionState;
644
645        fn busy_session() -> SessionState {
646            let mut s = SessionState::default();
647            s.set_task("refactor the payment retry loop", Some("edit"));
648            s.touch_file("src/payments/retry.rs", None, "full", 812);
649            s.add_finding(
650                Some("src/payments/retry.rs"),
651                Some(42),
652                "backoff ignores jitter",
653            );
654            s.add_decision("keep exponential backoff", None);
655            s
656        }
657        fn prefix(session: &SessionState) -> String {
658            let tools = serde_json::to_string(
659                &crate::core::tool_visibility::advertised_tool_defs_default(),
660            )
661            .expect("tool defs serialize");
662            let instructions = build_instructions_with_client_and_session(
663                CrpMode::effective(),
664                "claude-code",
665                session,
666            );
667            format!(
668                "{tools}\n{instructions}\n{}",
669                crate::rules_inject::canonical_rules_block()
670            )
671        }
672
673        let fresh = {
674            let _iso = crate::core::data_dir::isolated_data_dir();
675            prefix(&SessionState::default())
676        };
677        let busy = {
678            let _iso = crate::core::data_dir::isolated_data_dir();
679            prefix(&busy_session())
680        };
681        assert!(fresh == busy, "default prefix depends on session state");
682
683        // Negative probe: the same comparison must see session state once the
684        // instructions do carry it (minimal off), or it compares nothing. No
685        // client cap here: Claude Code's 2048-char cut would drop the block.
686        let _iso = crate::core::data_dir::isolated_data_dir();
687        let render = |s: &SessionState| {
688            build_full_instructions(
689                CrpMode::Off,
690                "",
691                false,
692                CompressionLevel::Off,
693                false,
694                "",
695                Some(s),
696            )
697        };
698        assert_ne!(render(&SessionState::default()), render(&busy_session()));
699    }
700
701    /// Claude Code's 2048 cap is in characters; a byte count over-trims text
702    /// with em-dashes/arrows, and a mid-line cut is exactly the host behaviour
703    /// this replaces.
704    #[test]
705    fn char_budget_cuts_at_line_ends_counting_chars() {
706        let line = "→".repeat(10); // 10 chars, 30 bytes
707        let text = format!("{line}\r\n{line}\r\n{line}");
708        // Each line is 10 chars + CRLF: 25 chars hold two whole lines, a
709        // byte count (30 bytes per line) would not even hold one.
710        assert_eq!(
711            trim_to_char_budget(&text, 25),
712            format!("{line}\r\n{line}"),
713            "CRLF-safe, whole lines"
714        );
715        assert_eq!(trim_to_char_budget(&text, 15), line);
716        assert_eq!(
717            trim_to_char_budget(&text, 100),
718            text,
719            "under budget untouched"
720        );
721        assert_eq!(
722            trim_to_char_budget(&"é".repeat(50), 7),
723            "é".repeat(7),
724            "one long line"
725        );
726        assert_eq!(client_instruction_char_cap("claude-code"), Some(2048));
727        assert_eq!(client_instruction_char_cap("cursor"), None);
728    }
729
730    #[test]
731    fn guidance_suffix_survives_oversized_base() {
732        let base = "SESSION LINE\n".repeat(4000);
733        let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
734        let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
735        assert!(out.contains("OUTPUT STYLE: expert-terse"));
736        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
737        assert!(out.len() < base.len());
738    }
739
740    #[test]
741    fn empty_client_never_dedups_compression() {
742        assert!(!client_loads_compression_from_file(""));
743        assert!(!client_loads_compression_from_file("totally-unknown-agent"));
744    }
745
746    #[test]
747    fn live_session_instructions_include_only_the_owned_session() {
748        let mut current = crate::core::session::SessionState::new();
749        current.add_finding(None, None, "current-instance-marker");
750        let mut other = crate::core::session::SessionState::new();
751        other.add_finding(None, None, "other-instance-marker");
752
753        let out = build_full_instructions(
754            CrpMode::Off,
755            "",
756            false,
757            CompressionLevel::Off,
758            false,
759            "",
760            Some(&current),
761        );
762
763        assert!(out.contains("current-instance-marker"));
764        assert!(!out.contains("other-instance-marker"));
765        assert!(!out.contains("never echo tool output"));
766    }
767
768    #[test]
769    fn covered_client_gets_anchor_instead_of_skeleton() {
770        // #578: a client whose rule file carries the canonical block must not
771        // pay for the full skeleton again in every MCP session.
772        let _guard = crate::core::data_dir::test_env_lock();
773        let tmp = tempfile::tempdir().unwrap();
774        let home = tmp.path();
775        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
776        let cfg = crate::core::config::Config::load();
777        let shadow = cfg.shadow_mode;
778        let tp = crate::core::tool_profiles::ToolProfile::Power;
779        std::fs::write(
780            home.join(".cursor/rules/lean-ctx.mdc"),
781            rc::render(
782                shadow,
783                Wrapper::Dedicated,
784                crate::core::config::CompressionLevel::Standard,
785                &tp,
786            ),
787        )
788        .unwrap();
789        let old_home = std::env::var("HOME").ok();
790        crate::test_env::set_var("HOME", home);
791        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
792
793        let covered = build_instructions_with_client(CrpMode::Off, "cursor");
794        let uncovered = build_instructions_with_client(CrpMode::Off, "some-other-agent");
795
796        if let Some(h) = old_home {
797            crate::test_env::set_var("HOME", h);
798        } else {
799            crate::test_env::remove_var("HOME");
800        }
801        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
802
803        assert!(
804            covered.contains(SKELETON_ANCHOR),
805            "covered client must get the anchor:\n{covered}"
806        );
807        if shadow {
808            assert!(
809                !covered.contains("auto-route"),
810                "covered shadow client must not re-pay the shadow nudge:\n{covered}"
811            );
812        } else {
813            assert!(
814                !covered.contains("lean-ctx routing"),
815                "covered client must not re-pay the skeleton:\n{covered}"
816            );
817        }
818        // The mdc also carries the compression block → level dedups to Off.
819        assert!(
820            !covered.contains("OUTPUT STYLE:"),
821            "covered client must not re-pay the compression prompt:\n{covered}"
822        );
823        // #987: uncovered client without hooks no longer gets shadow instruction.
824        // `effective_shadow` = false when no hooks installed, so it falls back
825        // to the non-shadow (MANDATORY MAPPING) skeleton regardless of config.
826        if shadow {
827            assert!(
828                !uncovered.contains("auto-route"),
829                "#987: uncovered client WITHOUT hooks must NOT get shadow nudge:\n{uncovered}"
830            );
831            assert!(
832                uncovered.contains("lean-ctx routing") || uncovered.contains("ctx_"),
833                "uncovered client without hooks gets explicit tool mapping:\n{uncovered}"
834            );
835        } else {
836            assert!(
837                uncovered.contains("lean-ctx routing"),
838                "uncovered client keeps the full skeleton:\n{uncovered}"
839            );
840        }
841        eprintln!(
842            "instructions footprint: covered={} tok, uncovered={} tok",
843            count_tokens(&covered),
844            count_tokens(&uncovered)
845        );
846        assert!(
847            count_tokens(&covered) < count_tokens(&uncovered),
848            "anchor path must be strictly cheaper"
849        );
850    }
851
852    #[test]
853    fn hook_covered_client_gets_hook_aware_anchor() {
854        // GL #1153: with lean-ctx hooks covering the native tools, the anchor
855        // must not repeat "ctx_* replaces native tools" — that is exactly the
856        // instruction dissonance the HookCovered profile removes.
857        let _guard = crate::core::data_dir::test_env_lock();
858        let tmp = tempfile::tempdir().unwrap();
859        let home = tmp.path();
860        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
861        let tp = crate::core::tool_profiles::ToolProfile::Power;
862        std::fs::write(
863            home.join(".cursor/rules/lean-ctx.mdc"),
864            rc::render(
865                false,
866                Wrapper::HookCovered,
867                crate::core::config::CompressionLevel::Off,
868                &tp,
869            ),
870        )
871        .unwrap();
872        std::fs::write(
873            home.join(".cursor/hooks.json"),
874            r#"{"version":1,"hooks":{"preToolUse":[
875                {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
876                {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
877            ]}}"#,
878        )
879        .unwrap();
880        let old_home = std::env::var("HOME").ok();
881        crate::test_env::set_var("HOME", home);
882        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
883
884        let covered = build_instructions_with_client(CrpMode::Off, "cursor");
885
886        if let Some(h) = old_home {
887            crate::test_env::set_var("HOME", h);
888        } else {
889            crate::test_env::remove_var("HOME");
890        }
891        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
892
893        assert!(
894            covered.contains("hooks compress native Shell/Read/Grep"),
895            "hook-covered client must get the hook-aware anchor:\n{covered}"
896        );
897        assert!(
898            !covered.contains("ctx_* tools replace native")
899                && !covered.contains("lean-ctx routing"),
900            "hook-covered client must not carry the replace-native wording:\n{covered}"
901        );
902    }
903
904    #[test]
905    fn non_coding_persona_block_lands_in_instructions() {
906        let _guard = crate::core::data_dir::test_env_lock();
907        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
908
909        crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
910        let research = build_instructions_with_client(CrpMode::Off, "");
911
912        crate::test_env::set_var("LEAN_CTX_PERSONA", "coding");
913        let coding = build_instructions_with_client(CrpMode::Off, "");
914
915        crate::test_env::remove_var("LEAN_CTX_PERSONA");
916        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
917
918        assert!(
919            research.contains("PERSONA: research"),
920            "research persona must announce its domain block:\n{research}"
921        );
922        assert!(
923            research.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
924            "research persona must carry its intent vocabulary:\n{research}"
925        );
926        assert!(
927            !coding.contains("PERSONA:"),
928            "the coding default must keep the instructions byte-stable (#498):\n{coding}"
929        );
930    }
931
932    #[test]
933    fn under_cap_keeps_everything() {
934        let base = "tool mapping block";
935        let suffix = "OUTPUT STYLE: dense";
936        let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
937        assert!(out.contains(base));
938        assert!(out.contains(suffix));
939    }
940
941    #[test]
942    fn empty_suffix_caps_base_only() {
943        let base = "x\n".repeat(4000);
944        let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
945        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
946    }
947
948    #[cfg(windows)]
949    #[test]
950    fn shell_hint_stays_within_its_budget() {
951        let hint = build_shell_hint();
952        let tokens = count_tokens(&hint);
953        assert!(
954            tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
955            "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
956        );
957    }
958
959    #[test]
960    fn minimal_overhead_instructions_stay_within_budget() {
961        const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
962            STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
963        let _iso = crate::core::data_dir::isolated_data_dir();
964        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
965        let out = build_instructions(CrpMode::Compact);
966        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
967        let tokens = count_tokens(&out);
968        assert!(
969            tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
970            "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
971        );
972    }
973
974    #[test]
975    fn static_skeleton_stays_within_budget() {
976        let _iso = crate::core::data_dir::isolated_data_dir();
977        // Pin compression Off so the measured skeleton — and thus this budget —
978        // is deterministic regardless of the dev's local compression_level (#498).
979        crate::test_env::set_var("LEAN_CTX_COMPRESSION", "off");
980        for (mode, base_budget) in [
981            (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
982            (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
983            (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
984        ] {
985            let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
986            let out = build_instructions_for_test(mode);
987            let tokens = count_tokens(&out);
988            assert!(
989                tokens <= budget,
990                "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
991            );
992        }
993        crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
994    }
995}