Skip to main content

lean_ctx/tools/
ctx_compose.rs

1//! `ctx_compose` — task composer (Phase 2 of the efficiency epic).
2//!
3//! The biggest agent win is a single "rich per call" tool that returns ranked
4//! files *with* inline bodies, replacing the typical search → read → outline →
5//! read chain (3-5 calls) with one.
6//!
7//! lean-ctx already has the building blocks as separate tools; this composes
8//! them into one response for a natural-language task:
9//!   1. extracted keywords,
10//!   2. semantically ranked files (BM25 / hybrid),
11//!   3. exact match locations (index-backed `ctx_search`),
12//!   4. the body of the most relevant symbol, inline.
13
14use std::collections::HashMap;
15use std::sync::mpsc;
16use std::time::Duration;
17
18use crate::core::graph_provider;
19use crate::core::tokens::count_tokens;
20use crate::tools::CrpMode;
21
22/// Wall-time budget for the semantic-ranking stage. The exact-match and symbol
23/// stages are index-backed and cheap; only semantic ranking can hit a cold
24/// `O(corpus)` BM25 build. We never let that block the agent loop: past the
25/// budget (4s, tuned for cold-start coverage #902) we return what we have and let the detached worker finish warming the
26/// resident cache for the next call. Override via `LEAN_CTX_COMPOSE_BUDGET_MS`.
27const DEFAULT_SEMANTIC_BUDGET_MS: u64 = 4000;
28
29fn semantic_budget() -> Duration {
30    let ms = std::env::var("LEAN_CTX_COMPOSE_BUDGET_MS")
31        .ok()
32        .and_then(|v| v.parse::<u64>().ok())
33        .filter(|&v| v > 0)
34        .unwrap_or(DEFAULT_SEMANTIC_BUDGET_MS);
35    Duration::from_millis(ms)
36}
37
38/// Token budget for the inlined symbol bodies. Submodular selection fills it
39/// with the most coverage-effective, non-redundant set of symbols.
40/// Override via `LEAN_CTX_COMPOSE_SYMBOL_TOKENS`.
41const DEFAULT_SYMBOL_BUDGET_TOKENS: usize = 600;
42
43fn symbol_budget_tokens() -> usize {
44    std::env::var("LEAN_CTX_COMPOSE_SYMBOL_TOKENS")
45        .ok()
46        .and_then(|v| v.parse::<usize>().ok())
47        .filter(|&v| v > 0)
48        .unwrap_or(DEFAULT_SYMBOL_BUDGET_TOKENS)
49}
50
51/// Wall-time budget for the associative (graph spreading-activation) stage.
52/// Opening/building the graph index is `O(corpus)` on a cold repo, so — like
53/// semantic ranking — we bound it and skip the (purely additive) section on
54/// overrun while the detached worker warms the index. `LEAN_CTX_COMPOSE_GRAPH_BUDGET_MS`.
55const DEFAULT_GRAPH_BUDGET_MS: u64 = 1500;
56
57fn graph_budget() -> Duration {
58    let ms = std::env::var("LEAN_CTX_COMPOSE_GRAPH_BUDGET_MS")
59        .ok()
60        .and_then(|v| v.parse::<u64>().ok())
61        .filter(|&v| v > 0)
62        .unwrap_or(DEFAULT_GRAPH_BUDGET_MS);
63    Duration::from_millis(ms)
64}
65
66/// Per-hop activation decay and hop count for spreading activation. Small decay
67/// keeps activation local (structurally near the seeds); 3 hops covers
68/// import→callee→sibling chains without diffusing across the whole graph.
69const SPREAD_DECAY: f64 = 0.6;
70const SPREAD_HOPS: usize = 3;
71/// How many associative neighbours to surface.
72const SPREAD_TOP_K: usize = 8;
73
74/// Build the associative-relevance block: spreading activation seeded at the
75/// files the task keywords resolve to, propagated over the union of the static
76/// import/call graph and the *learned* Hebbian co-access graph. Returns an empty
77/// string when no graph/seeds are available. Runs entirely in the worker thread
78/// so [`associative_block_budgeted`] can bound it.
79fn build_associative_block(project_root: &str, keywords: &[String]) -> String {
80    let Some(open) = graph_provider::open_or_build(project_root) else {
81        return String::new();
82    };
83    let gp = &open.provider;
84
85    // Seeds: distinct files the keywords resolve to via symbol lookup.
86    let mut seed_files: Vec<String> = Vec::new();
87    for kw in keywords {
88        for sym in gp.find_symbols(kw, None, None) {
89            if !seed_files.contains(&sym.file) {
90                seed_files.push(sym.file);
91            }
92        }
93    }
94    if seed_files.is_empty() {
95        return String::new();
96    }
97
98    // Hebbian update: files relevant to the same task "fire together", so record
99    // their co-access (strengthens future associative recall). Persisted.
100    crate::core::cooccurrence::record_access(project_root, &seed_files);
101
102    // Adjacency = static structural edges ∪ learned co-access edges. Edges are
103    // made bidirectional so activation spreads both up and down the graph.
104    let mut adjacency: HashMap<String, Vec<(String, f64)>> = HashMap::new();
105    let mut add_edge = |a: &str, b: &str, w: f64| {
106        adjacency
107            .entry(a.to_string())
108            .or_default()
109            .push((b.to_string(), w));
110        adjacency
111            .entry(b.to_string())
112            .or_default()
113            .push((a.to_string(), w));
114    };
115    for e in gp.edges() {
116        add_edge(&e.from, &e.to, if e.weight > 0.0 { e.weight } else { 1.0 });
117    }
118    let coaccess = crate::core::cooccurrence::load(project_root);
119    for sf in &seed_files {
120        for (nbr, w) in coaccess.related(sf, 16) {
121            add_edge(sf, &nbr, w);
122        }
123    }
124
125    let seeds: HashMap<String, f64> = seed_files.iter().map(|f| (f.clone(), 1.0)).collect();
126    let ranked = crate::core::spreading_activation::related_ranked(
127        &seeds,
128        &adjacency,
129        SPREAD_DECAY,
130        SPREAD_HOPS,
131        SPREAD_TOP_K,
132    );
133    if ranked.is_empty() {
134        return String::new();
135    }
136
137    let mut s = String::from("\n## Related (associative: import/call graph + learned co-access)\n");
138    for (file, activation) in ranked {
139        // Forward-slash normalize so Windows backslash paths are never escape-
140        // mangled by client render layers (issue #324).
141        let file = crate::core::protocol::display_path(&file);
142        s.push_str(&format!("- {file} (activation {activation:.2})\n"));
143    }
144    s
145}
146
147/// Run [`build_associative_block`] under [`graph_budget`]. The Hebbian record is
148/// a side effect of the worker, so it persists even when we time out and drop
149/// the (optional) section.
150fn associative_block_budgeted(project_root: &str, keywords: &[String]) -> String {
151    if keywords.is_empty() {
152        return String::new();
153    }
154    let (tx, rx) = mpsc::channel::<String>();
155    let root = project_root.to_string();
156    let kws = keywords.to_vec();
157    std::thread::spawn(move || {
158        let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
159            build_associative_block(&root, &kws)
160        }))
161        .unwrap_or_else(|_| {
162            tracing::warn!("[ctx_compose: associative block panicked; omitting section]");
163            String::new()
164        });
165        let _ = tx.send(block);
166    });
167    rx.recv_timeout(graph_budget()).unwrap_or_default()
168}
169
170/// Words that carry no retrieval signal — dropped from keyword extraction.
171const STOPWORDS: &[&str] = &[
172    "the",
173    "and",
174    "for",
175    "with",
176    "that",
177    "this",
178    "from",
179    "into",
180    "how",
181    "where",
182    "what",
183    "does",
184    "are",
185    "was",
186    "use",
187    "used",
188    "uses",
189    "add",
190    "all",
191    "any",
192    "can",
193    "get",
194    "set",
195    "via",
196    "out",
197    "its",
198    "his",
199    "her",
200    "you",
201    "your",
202    "our",
203    "find",
204    "show",
205    "list",
206    "make",
207    "when",
208    "then",
209    "has",
210    "have",
211    "had",
212    "not",
213    "but",
214    "see",
215    "function",
216    "method",
217    "class",
218    "code",
219    "file",
220    "files",
221    "implement",
222    "implementation",
223];
224
225/// Extract up to `max` distinct identifier-ish keywords from a task, preserving
226/// original case (symbol lookups are case-sensitive) and first-seen order.
227fn extract_keywords(task: &str, max: usize) -> Vec<String> {
228    let mut seen = std::collections::HashSet::new();
229    let mut out = Vec::new();
230    for raw in task.split(|c: char| !(c.is_alphanumeric() || c == '_')) {
231        if raw.len() < 3 {
232            continue;
233        }
234        if STOPWORDS.contains(&raw.to_ascii_lowercase().as_str()) {
235            continue;
236        }
237        if seen.insert(raw.to_string()) {
238            out.push(raw.to_string());
239            if out.len() >= max {
240                break;
241            }
242        }
243    }
244    out
245}
246
247/// Order `keywords` from most to least specific using the resident BM25 index's
248/// per-token document frequency (how many chunks contain the token). Rarer =
249/// more specific = better as the "exact matches" seed. A token absent from the
250/// corpus (df 0) sinks to the end — grepping it yields nothing useful.
251///
252/// Non-blocking and best-effort: if the resident index isn't warm yet we return
253/// the keywords in their original first-seen order (the previous behaviour), so
254/// this can only improve the seed, never stall the call to build an index.
255fn order_by_specificity(keywords: &[String], project_root: &str) -> Vec<String> {
256    let Some(index) = resident_index(project_root) else {
257        return keywords.to_vec();
258    };
259    rank_by_doc_freq(keywords, &index.doc_freqs)
260}
261
262/// Pure ranking core: choose the exact-match seed keyword.
263///
264/// The seed feeds a case-sensitive regex grep, so raw rarity is the wrong sort:
265/// the rarest task token is often a lowercase prose word (`measurand`) that the
266/// index counts case-insensitively but the grep then misses against `Measurand`
267/// — 0 hits, worse than before. Instead prefer *code identifiers* (camelCase or
268/// snake_case: `GetMaxCurrent`, `CurrentGetter`), which grep straight to code,
269/// over acronyms (`OCPP`) and prose (`current`) that also match READMEs. Within
270/// each class, rarer (lower document frequency) wins; absent tokens (df 0) sink.
271/// Keys are lowercased in `doc_freqs`; a stable sort keeps first-seen order on
272/// ties, so a task with no identifiers degrades to the previous rarity order.
273fn rank_by_doc_freq(
274    keywords: &[String],
275    doc_freqs: &std::collections::HashMap<String, usize>,
276) -> Vec<String> {
277    let df = |kw: &String| match doc_freqs.get(&kw.to_ascii_lowercase()) {
278        Some(&n) if n > 0 => n,
279        _ => usize::MAX,
280    };
281    // Class 0 = code identifier (grep-friendly), class 1 = acronym/prose.
282    let rank_key = |kw: &String| (u8::from(!is_code_identifier(kw)), df(kw));
283    let mut ranked = keywords.to_vec();
284    ranked.sort_by_key(rank_key);
285    ranked
286}
287
288/// True for tokens that read as code identifiers — snake_case (`get_max_current`)
289/// or camelCase/PascalCase with an internal capital (`GetMaxCurrent`). A leading
290/// capital alone (`Current`) or an all-caps acronym (`OCPP`) does not qualify:
291/// those match prose and file boilerplate as readily as code.
292fn is_code_identifier(kw: &str) -> bool {
293    if kw.contains('_') {
294        return true;
295    }
296    let has_lower = kw.chars().any(|c| c.is_ascii_lowercase());
297    let internal_upper = kw.chars().skip(1).any(|c| c.is_ascii_uppercase());
298    has_lower && internal_upper
299}
300
301/// Fetch the already-resident BM25 index for `project_root` without triggering a
302/// build. Returns `None` when nothing is cached yet (cold start).
303fn resident_index(
304    project_root: &str,
305) -> Option<std::sync::Arc<crate::core::bm25_index::BM25Index>> {
306    let cache = crate::tools::ctx_semantic_search::get_thread_cache()?;
307    crate::core::bm25_cache::get_or_background(&cache, std::path::Path::new(project_root))
308}
309
310/// Run the semantic ranking stage under a wall-time budget. Returns the ranked
311/// block on time, or a short "deferred" note if the (cold) build overruns —
312/// in which case the detached worker keeps running to warm the resident cache.
313fn ranked_files_budgeted(task: &str, project_root: &str, crp_mode: CrpMode) -> String {
314    let shared_cache = crate::tools::ctx_semantic_search::get_thread_cache();
315    let (tx, rx) = mpsc::channel::<String>();
316    let task_owned = task.to_string();
317    let root_owned = project_root.to_string();
318
319    std::thread::spawn(move || {
320        if let Some(cache) = shared_cache {
321            crate::tools::ctx_semantic_search::set_thread_cache(cache);
322        }
323        let ranked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
324            crate::tools::ctx_semantic_search::handle(
325                &task_owned,
326                &root_owned,
327                8,
328                crp_mode,
329                None,
330                None,
331                None,
332                Some(false),
333                Some(false),
334            )
335        }))
336        .unwrap_or_else(|_| {
337            tracing::warn!("[ctx_compose: semantic ranking panicked; omitting section]");
338            String::new()
339        });
340        // Receiver may be gone (we timed out); dropping the result is fine —
341        // the cache warming already happened as a side effect of the build.
342        let _ = tx.send(ranked);
343    });
344
345    match rx.recv_timeout(semantic_budget()) {
346        Ok(ranked) => ranked.trim().to_string(),
347        Err(_) => deferred_ranking_note(project_root),
348    }
349}
350
351/// Honest, state-aware note when semantic ranking overruns its wall-time budget.
352///
353/// The old message always promised ranking would be "instant on the next call".
354/// That is a lie when the index build *failed* or the index is too large to
355/// persist — in those cases every call rebuilds and the promise never comes
356/// true (issue #249: "keeps saying it's warming up … but it never happens").
357/// We now read the real orchestrator state and tell the agent exactly what is
358/// happening and what to do about it.
359fn deferred_ranking_note(project_root: &str) -> String {
360    let exact = "the exact matches below are authoritative for this call";
361    let s = crate::core::index_orchestrator::bm25_summary(project_root);
362    match s.state {
363        "failed" => {
364            let why = s
365                .last_error
366                .or(s.note)
367                .unwrap_or_else(|| "unknown error".to_string());
368            format!(
369                "(semantic ranking unavailable — index build FAILED: {why}. {exact}. \
370                 Inspect with `ctx_index status` / `lean-ctx doctor`, then `lean-ctx reindex`)"
371            )
372        }
373        "building" => {
374            let secs = s.elapsed_ms.map_or(0, |ms| ms / 1000);
375            format!(
376                "(deferred — semantic index is building ({secs}s elapsed); {exact}, \
377                 and ranking becomes available once the build finishes)"
378            )
379        }
380        // ready/idle: this call's cold build just overran the budget. If the
381        // index could not be persisted (too large), surface that — otherwise it
382        // silently rebuilds on every cold start and never gets faster.
383        _ => match s.note {
384            Some(note) if note.contains("NOT persisted") => {
385                format!("(semantic ranking deferred — {note} {exact}.)")
386            }
387            _ => format!(
388                "(deferred — semantic index is warming; {exact}, \
389                 and ranking will be fast on the next call once the index is cached)"
390            ),
391        },
392    }
393}
394
395/// Compose a single rich response for `task`.
396pub fn handle(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
397    let task = task.trim();
398    if task.is_empty() {
399        return ("ERROR: task is required".to_string(), 0);
400    }
401
402    let keywords = extract_keywords(task, 6);
403    let allow_secret = crate::core::roles::active_role().io.allow_secret_paths;
404
405    let mut out = String::new();
406    out.push_str(&format!("TASK: {task}\n"));
407    if keywords.is_empty() {
408        out.push_str("KEYWORDS: (none extracted — using full task for ranking)\n");
409    } else {
410        out.push_str(&format!("KEYWORDS: {}\n", keywords.join(", ")));
411    }
412
413    // 1. Semantically ranked files for the whole task — budgeted so a cold
414    //    BM25 build can never stall the agent loop (hardening H1). The worker
415    //    inherits the resident cache, so a build that overruns the budget still
416    //    warms the cache for the next call rather than being wasted.
417    out.push_str("\n## Ranked files (semantic)\n");
418    out.push_str(&ranked_files_budgeted(task, project_root, crp_mode));
419    out.push('\n');
420
421    // 2. Exact match locations for the most specific identifier-shaped keyword.
422    // Broad prose words and acronyms create repository-wide README/Dockerfile
423    // noise. Within identifiers, the resident index ranks the rarest one first.
424    let ranked_keywords = order_by_specificity(&keywords, project_root);
425    if let Some(primary) = ranked_keywords
426        .iter()
427        .find(|keyword| is_code_identifier(keyword))
428    {
429        let grep = crate::tools::ctx_search::handle(
430            primary,
431            project_root,
432            None,
433            10,
434            crp_mode,
435            true,
436            allow_secret,
437            false,
438        )
439        .text;
440        out.push_str(&format!("\n## Exact matches: '{primary}'\n"));
441        out.push_str(grep.trim());
442        out.push('\n');
443    }
444
445    // 3. Inline the symbol bodies that best cover the task keywords. Rather
446    //    than just the first match, select the non-redundant *set* of symbols
447    //    with maximal keyword coverage under a token budget via submodular
448    //    greedy (1−1/e optimal). Two keywords resolving to the same symbol, or
449    //    a symbol whose body adds no new keyword, are naturally pruned.
450    use crate::core::context_packing::{CoverageItem, greedy_max_coverage};
451    let mut snippets: Vec<String> = Vec::new();
452    let mut items: Vec<CoverageItem> = Vec::new();
453    for kw in &keywords {
454        if let Some((rendered, toks)) =
455            crate::tools::ctx_symbol::best_symbol_snippet_for_task(kw, task, project_root)
456        {
457            // The snippet always covers its triggering keyword, plus any other
458            // task keyword its body textually surfaces (a more central symbol).
459            let mut terms: std::collections::HashSet<String> =
460                std::collections::HashSet::from([kw.clone()]);
461            for other in &keywords {
462                if other != kw && rendered.contains(other.as_str()) {
463                    terms.insert(other.clone());
464                }
465            }
466            items.push(CoverageItem {
467                terms,
468                cost: toks.max(1),
469            });
470            snippets.push(rendered);
471        }
472    }
473    if !items.is_empty() {
474        let chosen = greedy_max_coverage(&items, symbol_budget_tokens(), |_| 1.0);
475        let mut seen = std::collections::HashSet::new();
476        let mut header_written = false;
477        for idx in chosen {
478            let rendered = snippets[idx].trim();
479            if rendered.is_empty() || !seen.insert(rendered.to_string()) {
480                continue;
481            }
482            if !header_written {
483                out.push_str("\n## Top symbols (bodies)\n");
484                header_written = true;
485            }
486            out.push_str(rendered);
487            out.push('\n');
488        }
489    }
490
491    // 4. Associative neighbours via spreading activation over the import/call
492    //    graph unified with the learned Hebbian co-access graph (budgeted,
493    //    additive — surfaces structurally-close files lexical search misses).
494    out.push_str(&associative_block_budgeted(project_root, &keywords));
495
496    // 5. Context Kernel enrichment — cross-store context from Knowledge,
497    //    Episodic, and Procedural memory that the lexical pipeline misses.
498    //    Budget: 20% of symbol budget. Graceful no-op if kernel returns None.
499    {
500        use crate::core::context_kernel::activation::{load_config, supplement_budget};
501        use crate::core::context_kernel::context_dedup::dedup_kernel_blocks;
502
503        let config = load_config(project_root);
504        let budget = symbol_budget_tokens() / 5;
505        let budget = budget
506            .min(config.max_supplement_tokens)
507            .min(supplement_budget(&config));
508        if let Some(enrichment) =
509            crate::core::context_kernel::bridge::kernel_enrich(task, project_root, budget)
510                .filter(|enrichment| !enrichment.blocks.is_empty())
511        {
512            let blocks =
513                dedup_kernel_blocks(&enrichment.blocks, &mut std::collections::HashSet::new());
514            if !blocks.is_empty() {
515                out.push_str("\n## Context Kernel\n");
516                out.push_str(&blocks);
517                out.push('\n');
518            }
519        }
520    }
521
522    let sent = count_tokens(&out);
523    (out, sent)
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn rank_by_doc_freq_puts_rare_identifier_first() {
532        // The evcc/#993 shape: an "OCPP … GetMaxCurrent" task. `Current` and
533        // `OCPP` are common tokens; `GetMaxCurrent` is rare. The rare one must
534        // seed the exact-match grep so it lands on code, not README/Dockerfile.
535        let keywords = vec![
536            "OCPP".to_string(),
537            "GetMaxCurrent".to_string(),
538            "Current".to_string(),
539        ];
540        let doc_freqs = std::collections::HashMap::from([
541            ("ocpp".to_string(), 120),
542            ("current".to_string(), 400),
543            ("getmaxcurrent".to_string(), 3),
544        ]);
545        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
546        assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
547        assert_eq!(ranked.last().unwrap(), "Current");
548    }
549
550    #[test]
551    fn rank_by_doc_freq_sinks_absent_tokens_and_is_stable() {
552        // A token absent from the corpus (df 0) is useless as a grep seed and
553        // must sort last; equal-df tokens keep their original order.
554        let keywords = vec![
555            "absent".to_string(),
556            "alpha".to_string(),
557            "beta".to_string(),
558        ];
559        let doc_freqs =
560            std::collections::HashMap::from([("alpha".to_string(), 5), ("beta".to_string(), 5)]);
561        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
562        assert_eq!(ranked, vec!["alpha", "beta", "absent"]);
563    }
564
565    #[test]
566    fn rank_prefers_code_identifier_over_rarer_prose_word() {
567        // The regression the case-sensitive grep exposed: a rarer lowercase prose
568        // token (`measurand`, df 4) must NOT beat a camelCase identifier
569        // (`GetMaxCurrent`, df 30) as the seed — the identifier greps to code,
570        // the prose word whiffs against `Measurand`.
571        let keywords = vec!["measurand".to_string(), "GetMaxCurrent".to_string()];
572        let doc_freqs = std::collections::HashMap::from([
573            ("measurand".to_string(), 4),
574            ("getmaxcurrent".to_string(), 30),
575        ]);
576        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
577        assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
578    }
579
580    #[test]
581    fn is_code_identifier_classifies_camel_snake_vs_prose_and_acronym() {
582        assert!(is_code_identifier("GetMaxCurrent"));
583        assert!(is_code_identifier("CurrentGetter"));
584        assert!(is_code_identifier("get_max_current"));
585        // Leading-cap word and all-caps acronym are not code identifiers.
586        assert!(!is_code_identifier("Current"));
587        assert!(!is_code_identifier("OCPP"));
588        assert!(!is_code_identifier("charger"));
589    }
590
591    #[test]
592    fn extract_keywords_drops_stopwords_and_short_tokens() {
593        let kw = extract_keywords("How does the BM25Index cache work for ctx_search?", 6);
594        assert!(kw.contains(&"BM25Index".to_string()));
595        assert!(kw.contains(&"cache".to_string()));
596        assert!(kw.contains(&"ctx_search".to_string()));
597        assert!(!kw.iter().any(|k| k == "the" || k == "How" || k == "for"));
598    }
599
600    #[test]
601    fn extract_keywords_dedups_and_caps() {
602        let kw = extract_keywords("alpha alpha beta gamma delta epsilon zeta eta", 3);
603        assert_eq!(kw.len(), 3);
604        assert_eq!(kw[0], "alpha");
605    }
606
607    #[test]
608    fn exact_matches_choose_specific_identifier_not_first_broad_keyword() {
609        let keywords = extract_keywords(
610            "OCPP charger GetMaxCurrent Current.Offered measurand CurrentGetter",
611            6,
612        );
613        assert!(keywords.iter().any(|keyword| keyword == "GetMaxCurrent"));
614        assert!(keywords.iter().any(|keyword| is_code_identifier(keyword)));
615        assert!(!is_code_identifier("OCPP"));
616
617        let prose = extract_keywords("Fix semantic ranking exact matches", 6);
618        assert!(prose.iter().all(|keyword| !is_code_identifier(keyword)));
619    }
620
621    #[test]
622    fn empty_task_is_rejected() {
623        let (out, tok) = handle("   ", "/tmp", CrpMode::Off);
624        assert!(out.starts_with("ERROR"));
625        assert_eq!(tok, 0);
626    }
627
628    #[test]
629    fn handle_includes_context_kernel_section_when_available() {
630        let (output, tokens) = handle("find authentication bugs", "/tmp/nonexistent", CrpMode::Tdd);
631        // The kernel may or may not produce output for a nonexistent project,
632        // but handle() must not panic.
633        assert!(tokens > 0);
634        assert!(output.contains("TASK:"));
635    }
636
637    #[test]
638    fn deferred_note_for_idle_index_is_optimistic_but_honest() {
639        // Unknown project → orchestrator state is idle. The note must NOT promise
640        // "instant on the next call" (the dishonest wording from #249); it should
641        // explain the index is warming and will be fast once cached.
642        let tmp = tempfile::tempdir().unwrap();
643        let note = deferred_ranking_note(tmp.path().to_string_lossy().as_ref());
644        assert!(
645            note.contains("warming") || note.contains("building"),
646            "note: {note}"
647        );
648        assert!(
649            note.contains("authoritative"),
650            "note must reassure that exact matches are authoritative: {note}"
651        );
652        assert!(
653            !note.contains("instant on the next call"),
654            "must not repeat the dishonest 'instant next call' promise: {note}"
655        );
656    }
657}