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" => format!(
374            "(deferred — semantic index is building; {exact}, \
375             and ranking becomes available once the build finishes)"
376        ),
377        // ready/idle: this call's cold build just overran the budget. If the
378        // index could not be persisted (too large), surface that — otherwise it
379        // silently rebuilds on every cold start and never gets faster.
380        _ => match s.note {
381            Some(note) if note.contains("NOT persisted") => {
382                format!("(semantic ranking deferred — {note} {exact}.)")
383            }
384            _ => format!(
385                "(deferred — semantic index is warming; {exact}, \
386                 and ranking will be fast on the next call once the index is cached)"
387            ),
388        },
389    }
390}
391
392/// Compose a single rich response for `task`.
393pub fn handle(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
394    let task = task.trim();
395    if task.is_empty() {
396        return ("ERROR: task is required".to_string(), 0);
397    }
398
399    let keywords = extract_keywords(task, 6);
400    let allow_secret = crate::core::roles::active_role().io.allow_secret_paths;
401
402    let mut out = String::new();
403    out.push_str(&format!("TASK: {task}\n"));
404    if keywords.is_empty() {
405        out.push_str("KEYWORDS: (none extracted — using full task for ranking)\n");
406    } else {
407        out.push_str(&format!("KEYWORDS: {}\n", keywords.join(", ")));
408    }
409
410    // 1. Semantically ranked files for the whole task — budgeted so a cold
411    //    BM25 build can never stall the agent loop (hardening H1). The worker
412    //    inherits the resident cache, so a build that overruns the budget still
413    //    warms the cache for the next call rather than being wasted.
414    out.push_str("\n## Ranked files (semantic)\n");
415    out.push_str(&ranked_files_budgeted(task, project_root, crp_mode));
416    out.push('\n');
417
418    // 2. Exact match locations for the most specific identifier-shaped keyword.
419    // Broad prose words and acronyms create repository-wide README/Dockerfile
420    // noise. Within identifiers, the resident index ranks the rarest one first.
421    let ranked_keywords = order_by_specificity(&keywords, project_root);
422    if let Some(primary) = ranked_keywords
423        .iter()
424        .find(|keyword| is_code_identifier(keyword))
425    {
426        let grep = crate::tools::ctx_search::handle(
427            primary,
428            project_root,
429            None,
430            10,
431            crp_mode,
432            true,
433            allow_secret,
434            false,
435        )
436        .text;
437        out.push_str(&format!("\n## Exact matches: '{primary}'\n"));
438        out.push_str(grep.trim());
439        out.push('\n');
440    }
441
442    // 3. Inline the symbol bodies that best cover the task keywords. Rather
443    //    than just the first match, select the non-redundant *set* of symbols
444    //    with maximal keyword coverage under a token budget via submodular
445    //    greedy (1−1/e optimal). Two keywords resolving to the same symbol, or
446    //    a symbol whose body adds no new keyword, are naturally pruned.
447    use crate::core::context_packing::{CoverageItem, greedy_max_coverage};
448    let mut snippets: Vec<String> = Vec::new();
449    let mut items: Vec<CoverageItem> = Vec::new();
450    for kw in &keywords {
451        if let Some((rendered, toks)) =
452            crate::tools::ctx_symbol::best_symbol_snippet_for_task(kw, task, project_root)
453        {
454            // The snippet always covers its triggering keyword, plus any other
455            // task keyword its body textually surfaces (a more central symbol).
456            let mut terms: std::collections::HashSet<String> =
457                std::collections::HashSet::from([kw.clone()]);
458            for other in &keywords {
459                if other != kw && rendered.contains(other.as_str()) {
460                    terms.insert(other.clone());
461                }
462            }
463            items.push(CoverageItem {
464                terms,
465                cost: toks.max(1),
466            });
467            snippets.push(rendered);
468        }
469    }
470    if !items.is_empty() {
471        let chosen = greedy_max_coverage(&items, symbol_budget_tokens(), |_| 1.0);
472        let mut seen = std::collections::HashSet::new();
473        let mut header_written = false;
474        for idx in chosen {
475            let rendered = snippets[idx].trim();
476            if rendered.is_empty() || !seen.insert(rendered.to_string()) {
477                continue;
478            }
479            if !header_written {
480                out.push_str("\n## Top symbols (bodies)\n");
481                header_written = true;
482            }
483            out.push_str(rendered);
484            out.push('\n');
485        }
486    }
487
488    // 4. Associative neighbours via spreading activation over the import/call
489    //    graph unified with the learned Hebbian co-access graph (budgeted,
490    //    additive — surfaces structurally-close files lexical search misses).
491    out.push_str(&associative_block_budgeted(project_root, &keywords));
492
493    // 5. Context Kernel enrichment — cross-store context from Knowledge,
494    //    Episodic, and Procedural memory that the lexical pipeline misses.
495    //    Budget: 20% of symbol budget. Graceful no-op if kernel returns None.
496    {
497        use crate::core::context_kernel::activation::{load_config, supplement_budget};
498        use crate::core::context_kernel::context_dedup::dedup_kernel_blocks;
499
500        let config = load_config(project_root);
501        let budget = symbol_budget_tokens() / 5;
502        let budget = budget
503            .min(config.max_supplement_tokens)
504            .min(supplement_budget(&config));
505        if let Some(enrichment) =
506            crate::core::context_kernel::bridge::kernel_enrich(task, project_root, budget)
507                .filter(|enrichment| !enrichment.blocks.is_empty())
508        {
509            let blocks =
510                dedup_kernel_blocks(&enrichment.blocks, &mut std::collections::HashSet::new());
511            if !blocks.is_empty() {
512                out.push_str("\n## Context Kernel\n");
513                out.push_str(&blocks);
514                out.push('\n');
515            }
516        }
517    }
518
519    let sent = count_tokens(&out);
520    (out, sent)
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn rank_by_doc_freq_puts_rare_identifier_first() {
529        // The evcc/#993 shape: an "OCPP … GetMaxCurrent" task. `Current` and
530        // `OCPP` are common tokens; `GetMaxCurrent` is rare. The rare one must
531        // seed the exact-match grep so it lands on code, not README/Dockerfile.
532        let keywords = vec![
533            "OCPP".to_string(),
534            "GetMaxCurrent".to_string(),
535            "Current".to_string(),
536        ];
537        let doc_freqs = std::collections::HashMap::from([
538            ("ocpp".to_string(), 120),
539            ("current".to_string(), 400),
540            ("getmaxcurrent".to_string(), 3),
541        ]);
542        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
543        assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
544        assert_eq!(ranked.last().unwrap(), "Current");
545    }
546
547    #[test]
548    fn rank_by_doc_freq_sinks_absent_tokens_and_is_stable() {
549        // A token absent from the corpus (df 0) is useless as a grep seed and
550        // must sort last; equal-df tokens keep their original order.
551        let keywords = vec![
552            "absent".to_string(),
553            "alpha".to_string(),
554            "beta".to_string(),
555        ];
556        let doc_freqs =
557            std::collections::HashMap::from([("alpha".to_string(), 5), ("beta".to_string(), 5)]);
558        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
559        assert_eq!(ranked, vec!["alpha", "beta", "absent"]);
560    }
561
562    #[test]
563    fn rank_prefers_code_identifier_over_rarer_prose_word() {
564        // The regression the case-sensitive grep exposed: a rarer lowercase prose
565        // token (`measurand`, df 4) must NOT beat a camelCase identifier
566        // (`GetMaxCurrent`, df 30) as the seed — the identifier greps to code,
567        // the prose word whiffs against `Measurand`.
568        let keywords = vec!["measurand".to_string(), "GetMaxCurrent".to_string()];
569        let doc_freqs = std::collections::HashMap::from([
570            ("measurand".to_string(), 4),
571            ("getmaxcurrent".to_string(), 30),
572        ]);
573        let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
574        assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
575    }
576
577    #[test]
578    fn is_code_identifier_classifies_camel_snake_vs_prose_and_acronym() {
579        assert!(is_code_identifier("GetMaxCurrent"));
580        assert!(is_code_identifier("CurrentGetter"));
581        assert!(is_code_identifier("get_max_current"));
582        // Leading-cap word and all-caps acronym are not code identifiers.
583        assert!(!is_code_identifier("Current"));
584        assert!(!is_code_identifier("OCPP"));
585        assert!(!is_code_identifier("charger"));
586    }
587
588    #[test]
589    fn extract_keywords_drops_stopwords_and_short_tokens() {
590        let kw = extract_keywords("How does the BM25Index cache work for ctx_search?", 6);
591        assert!(kw.contains(&"BM25Index".to_string()));
592        assert!(kw.contains(&"cache".to_string()));
593        assert!(kw.contains(&"ctx_search".to_string()));
594        assert!(!kw.iter().any(|k| k == "the" || k == "How" || k == "for"));
595    }
596
597    #[test]
598    fn extract_keywords_dedups_and_caps() {
599        let kw = extract_keywords("alpha alpha beta gamma delta epsilon zeta eta", 3);
600        assert_eq!(kw.len(), 3);
601        assert_eq!(kw[0], "alpha");
602    }
603
604    #[test]
605    fn exact_matches_choose_specific_identifier_not_first_broad_keyword() {
606        let keywords = extract_keywords(
607            "OCPP charger GetMaxCurrent Current.Offered measurand CurrentGetter",
608            6,
609        );
610        assert!(keywords.iter().any(|keyword| keyword == "GetMaxCurrent"));
611        assert!(keywords.iter().any(|keyword| is_code_identifier(keyword)));
612        assert!(!is_code_identifier("OCPP"));
613
614        let prose = extract_keywords("Fix semantic ranking exact matches", 6);
615        assert!(prose.iter().all(|keyword| !is_code_identifier(keyword)));
616    }
617
618    #[test]
619    fn empty_task_is_rejected() {
620        let (out, tok) = handle("   ", "/tmp", CrpMode::Off);
621        assert!(out.starts_with("ERROR"));
622        assert_eq!(tok, 0);
623    }
624
625    #[test]
626    fn handle_includes_context_kernel_section_when_available() {
627        let (output, tokens) = handle("find authentication bugs", "/tmp/nonexistent", CrpMode::Tdd);
628        // The kernel may or may not produce output for a nonexistent project,
629        // but handle() must not panic.
630        assert!(tokens > 0);
631        assert!(output.contains("TASK:"));
632    }
633
634    #[test]
635    fn deferred_ranking_note_is_deterministic_and_has_no_timing() {
636        // Issue #498 / #1366: elapsed_ms must never appear in the note — it
637        // varies between calls and defeats provider prompt caching.
638        let tmp = tempfile::tempdir().unwrap();
639        let root = tmp.path().to_string_lossy();
640        let a = deferred_ranking_note(root.as_ref());
641        let b = deferred_ranking_note(root.as_ref());
642        assert_eq!(a, b, "deferred note must be byte-stable across calls");
643        assert!(
644            !a.contains("elapsed"),
645            "deferred note must not embed timing data: {a}"
646        );
647    }
648
649    #[test]
650    fn deferred_note_for_idle_index_is_optimistic_but_honest() {
651        // Unknown project → orchestrator state is idle. The note must NOT promise
652        // "instant on the next call" (the dishonest wording from #249); it should
653        // explain the index is warming and will be fast once cached.
654        let tmp = tempfile::tempdir().unwrap();
655        let note = deferred_ranking_note(tmp.path().to_string_lossy().as_ref());
656        assert!(
657            note.contains("warming") || note.contains("building"),
658            "note: {note}"
659        );
660        assert!(
661            note.contains("authoritative"),
662            "note must reassure that exact matches are authoritative: {note}"
663        );
664        assert!(
665            !note.contains("instant on the next call"),
666            "must not repeat the dishonest 'instant next call' promise: {note}"
667        );
668    }
669}