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