Skip to main content

lean_ctx/core/
tool_lifecycle.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared tool lifecycle — ensures CLI and MCP paths have identical side effects.
4//!
5//! The MCP server dispatcher handles session, ledger, heatmap, intent detection,
6//! and knowledge consolidation inline (via in-memory state). When the daemon is
7//! unavailable, CLI commands call functions here to achieve the same coverage by
8//! loading/saving state from disk.
9//!
10//! NOTE: When the daemon IS running, CLI routes through `daemon_client` which
11//! calls the MCP server — these functions are NOT called in that path.
12
13use crate::core::context_ir::{ContextIrSourceKindV1, ContextIrV1, RecordIrInput};
14use crate::core::context_ledger::ContextLedger;
15use crate::core::heatmap;
16use crate::core::intent_engine::StructuredIntent;
17use crate::core::session::SessionState;
18use crate::core::stats;
19use std::sync::atomic::{AtomicU32, Ordering};
20
21/// How many recently-touched files form the "working set" a new read is
22/// associated with for traversal (co-access) edges (#289). Small, so the signal
23/// stays local to what the agent is actively juggling.
24const PERIODIC_FLUSH_INTERVAL: u32 = 50;
25static TOOL_CALL_COUNT: AtomicU32 = AtomicU32::new(0);
26
27/// Whether `root` is a usable project root for repo-relative normalization.
28pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
29    root.filter(|r| !r.trim().is_empty() && *r != ".")
30}
31
32/// First 200 chars of `text` on a UTF-8 boundary — the exact excerpt bound the
33/// MCP dispatcher applies before handing content to the IR store
34/// (`server/call_tool.rs`), kept identical so CLI- and MCP-recorded IR items are
35/// byte-compatible. `ContextIrV1::record` redacts and further caps it.
36pub fn ir_excerpt(text: &str) -> &str {
37    const MAX: usize = 200;
38    if text.len() <= MAX {
39        return text;
40    }
41    let mut end = MAX;
42    while end > 0 && !text.is_char_boundary(end) {
43        end -= 1;
44    }
45    &text[..end]
46}
47
48/// Record a file-read operation with full Context OS side effects.
49///
50/// `duration` and `output_excerpt` feed the Context IR lineage (#566); the MCP
51/// dispatcher records both for every tool call but the shadow-mode `lean-ctx
52/// read` subprocess used to drop them, so IR/`ctx_proof` exports were blind to
53/// compressed shadow reads.
54pub fn record_file_read(
55    path: &str,
56    mode: &str,
57    original_tokens: usize,
58    output_tokens: usize,
59    is_cache_hit: bool,
60    duration: std::time::Duration,
61    output_excerpt: &str,
62) {
63    record_file_read_accounting(
64        path,
65        mode,
66        original_tokens,
67        output_tokens,
68        is_cache_hit,
69        duration,
70    );
71    let mut ir = ContextIrV1::load();
72    ir.record(RecordIrInput {
73        kind: ContextIrSourceKindV1::Read,
74        tool: "ctx_read",
75        client_name: None,
76        agent_id: None,
77        path: Some(path),
78        command: None,
79        pattern: Some(mode),
80        input_tokens: original_tokens,
81        output_tokens,
82        duration,
83        content_excerpt: ir_excerpt(output_excerpt),
84    });
85    ir.save();
86    record_file_read_projection(path, mode, original_tokens, output_tokens);
87    if mode == "aggressive" && original_tokens > output_tokens {
88        maybe_periodic_flush();
89    }
90    maybe_periodic_flush();
91}
92
93/// Compatibility accounting without IR persistence or an independent flush.
94/// The CLI lifecycle calls this once in its accounting stage.
95pub fn record_file_read_accounting(
96    path: &str,
97    mode: &str,
98    original_tokens: usize,
99    output_tokens: usize,
100    is_cache_hit: bool,
101    duration: std::time::Duration,
102) {
103    let saved = original_tokens.saturating_sub(output_tokens);
104    let tool_key = format!("cli_{mode}");
105
106    stats::record(&tool_key, original_tokens, output_tokens);
107    crate::core::execution_lifecycle::record_heatmap_access(path, original_tokens, saved);
108
109    // Emit event so the live dashboard feed sees hook-intercepted reads.
110    crate::core::events::emit_tool_call(
111        &tool_key,
112        original_tokens as u64,
113        saved as u64,
114        Some(mode.to_string()),
115        duration.as_millis() as u64,
116        Some(path.to_string()),
117    );
118    if is_cache_hit {
119        crate::core::events::emit_cache_hit(path, saved as u64);
120    }
121    // Verified ledger (#685): recorded explicitly now that the heatmap chokepoint
122    // no longer bundles it. This direct-CLI path (daemon off) only has o200k
123    // counts; the model-correct re-tokenization happens on the MCP read path,
124    // which holds the source text. For the default O200kBase model these are
125    // identical anyway.
126    crate::core::savings_ledger::record_read_event(original_tokens, saved, None, None);
127
128    if is_cache_hit {
129        crate::core::telemetry::global_metrics().record_cache(true);
130    }
131
132    // Session accounting goes into the caller's own session only. A CLI or
133    // hook process has none and writes none: the project's latest saved
134    // session may be another agent's (I07/S03).
135    let session_root = crate::core::session::caller::with_mut(|session| {
136        session.touch_file(path, None, mode, original_tokens);
137        if is_cache_hit {
138            session.record_cache_hit();
139        }
140        if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
141            let touched: Vec<String> = session
142                .files_touched
143                .iter()
144                .map(|ft| ft.path.clone())
145                .collect();
146            let inferred = StructuredIntent::from_file_patterns(&touched);
147            if inferred.confidence >= 0.4 {
148                session.active_structured_intent = Some(inferred);
149            }
150        }
151        session.project_root.clone()
152    })
153    .flatten();
154
155    // Project root the read observation below is scoped to: the caller's
156    // project, else the read file's own project. Defaults to "." (the MCP
157    // path's `project_root_snapshot` fallback) so a rootless read is still
158    // observed rather than dropped.
159    let learning_root = usable_root(session_root.as_deref())
160        .map(str::to_string)
161        .or_else(|| crate::core::protocol::detect_project_root(path))
162        .unwrap_or_else(|| String::from("."));
163
164    // Only real files belong in the context ledger (GL #512): directory
165    // overviews and synthetic paths would show up as "files" in the pressure
166    // table with eviction/pin semantics that make no sense for them.
167    if std::path::Path::new(path).is_file() {
168        let mut ledger = ContextLedger::load();
169        ledger.record(path, mode, original_tokens, output_tokens);
170        ledger.save();
171    }
172
173    // The read observation the MCP read path records in a background thread but
174    // the CLI path historically skipped for shadow-mode (`view`/`grep` →
175    // `lean-ctx read`) hook reads (#550). Run inline: a single-shot CLI process
176    // must finish it before it flushes and exits.
177    record_read_observation(path, mode, &learning_root);
178}
179
180pub fn record_file_read_projection(
181    path: &str,
182    mode: &str,
183    original_tokens: usize,
184    output_tokens: usize,
185) {
186    let saved = original_tokens.saturating_sub(output_tokens);
187    // OCLA CompressionProvider runtime projection: for aggressive-mode reads with
188    // positive savings, record the compression event through the canonical OCLA
189    // capability so the registry tracks real compression evidence.
190    if saved > 0 {
191        project_ocla_savings(path, original_tokens as u64, output_tokens as u64);
192    }
193    if mode == "aggressive" && saved > 0 {
194        project_ocla_compression(path, original_tokens as u64, output_tokens as u64);
195    }
196}
197
198/// Record the read as an OCLA observation for the standalone CLI path, as the
199/// MCP read path does (#550). A read is observed delivery, never task
200/// acceptance: the canonical execution protocol alone admits outcome-based
201/// learning.
202fn record_read_observation(path: &str, resolved_mode: &str, project_root: &str) {
203    let _ = crate::core::ocla::OclaRegistry::global()
204        .outcome_tracker
205        .record_outcome(read_observation(path, resolved_mode, project_root));
206}
207
208fn read_observation(
209    path: &str,
210    resolved_mode: &str,
211    project_root: &str,
212) -> crate::core::ocla::Outcome {
213    let context = crate::core::ocla::OclaRequestContext {
214        request_id: format!("cli-read:{path}:{resolved_mode}"),
215        session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
216        agent_id: "lean-ctx".to_string(),
217        content_ref: format!("file:{path}"),
218        tenant_id: None,
219        trace_id: "tr-unit".into(),
220        task_id: None,
221        parent_task_id: None,
222    };
223    crate::core::ocla::Outcome {
224        context,
225        accepted: None,
226        quality_score_milli: None,
227        outcome_ref: Some(format!("read:{project_root}:{resolved_mode}")),
228    }
229}
230
231/// Record a search/grep operation with full Context OS side effects.
232///
233/// `modeled_baseline` (native-tool estimate, GL #479 D1) feeds the estimated
234/// stats series; `observed_tokens` (raw measured match lines, no factor) feeds
235/// the verified ledger (GL #479 D2). `pattern`/`path`/`duration`/`output_excerpt`
236/// feed the Context IR lineage (#566).
237#[cfg(test)]
238pub(crate) fn record_search(
239    modeled_baseline: usize,
240    observed_tokens: usize,
241    output_tokens: usize,
242    pattern: &str,
243    path: &str,
244    duration: std::time::Duration,
245    output_excerpt: &str,
246) {
247    record_search_accounting(
248        modeled_baseline,
249        observed_tokens,
250        output_tokens,
251        path,
252        duration,
253    );
254    let mut ir = ContextIrV1::load();
255    ir.record(RecordIrInput {
256        kind: ContextIrSourceKindV1::Search,
257        tool: "ctx_search",
258        client_name: None,
259        agent_id: None,
260        path: Some(path),
261        command: None,
262        pattern: Some(pattern),
263        input_tokens: observed_tokens,
264        output_tokens,
265        duration,
266        content_excerpt: ir_excerpt(output_excerpt),
267    });
268    ir.save();
269}
270
271/// Existing search bookkeeping, separated from the IR writer for stage ownership.
272pub fn record_search_accounting(
273    modeled_baseline: usize,
274    observed_tokens: usize,
275    output_tokens: usize,
276    path: &str,
277    duration: std::time::Duration,
278) {
279    stats::record("cli_grep", modeled_baseline, output_tokens);
280
281    // Emit event so the live dashboard feed sees shadow-mode search.
282    let search_saved = modeled_baseline.saturating_sub(output_tokens);
283    if search_saved > 0 {
284        crate::core::events::emit_tool_call(
285            "cli_grep",
286            modeled_baseline as u64,
287            search_saved as u64,
288            Some("search".to_string()),
289            duration.as_millis() as u64,
290            Some(path.to_string()),
291        );
292    }
293    crate::core::savings_ledger::record_tool_event(
294        "cli_grep",
295        observed_tokens,
296        output_tokens,
297        None,
298        None,
299    );
300    crate::core::savings_tracker::record_compression(
301        modeled_baseline as u64,
302        output_tokens as u64,
303        "cli_grep",
304    );
305
306    crate::core::session::caller::with_mut(SessionState::record_command);
307}
308
309/// Record a tree/ls operation with full Context OS side effects.
310pub fn record_tree(original_tokens: usize, output_tokens: usize) {
311    stats::record("cli_ls", original_tokens, output_tokens);
312
313    // Emit event so the live dashboard feed sees shadow-mode tree/ls.
314    let saved = original_tokens.saturating_sub(output_tokens);
315    if saved > 0 {
316        crate::core::events::emit_tool_call(
317            "cli_ls",
318            original_tokens as u64,
319            saved as u64,
320            Some("tree".to_string()),
321            0,
322            None,
323        );
324    }
325
326    crate::core::session::caller::with_mut(SessionState::record_command);
327}
328
329/// Record a shell command with full Context OS side effects.
330/// Always records in stats (even for track-only 0-token calls) so the dashboard
331/// command counter stays accurate. Adding 0 tokens does not inflate savings.
332pub(crate) fn record_shell_command(original_tokens: usize, output_tokens: usize) {
333    record_shell_command_named("(shell)", original_tokens, output_tokens);
334}
335
336pub(crate) fn record_shell_command_named(
337    command: &str,
338    original_tokens: usize,
339    output_tokens: usize,
340) {
341    stats::record("cli_shell", original_tokens, output_tokens);
342
343    // Emit event so the live dashboard feed sees shadow-mode shell compression.
344    let saved = original_tokens.saturating_sub(output_tokens);
345    if saved > 0 {
346        crate::core::events::emit_tool_call(
347            "cli_shell",
348            original_tokens as u64,
349            saved as u64,
350            Some("shell".to_string()),
351            0,
352            None,
353        );
354    }
355    // Shell compression is *measured* (raw output vs sent output), so it belongs
356    // in the verified ledger too (GL #479 D2). Zero-saving calls are skipped.
357    crate::core::savings_ledger::record_tool_event(
358        "cli_shell",
359        original_tokens,
360        output_tokens,
361        None,
362        None,
363    );
364    crate::core::savings_tracker::record_compression(
365        original_tokens as u64,
366        output_tokens as u64,
367        "cli_shell",
368    );
369
370    if original_tokens > 0 {
371        let label = shell_ledger_label(command);
372        let mut ledger = crate::core::context_ledger::ContextLedger::load();
373        ledger.record_shell(&label, original_tokens, output_tokens);
374        ledger.save();
375    }
376    crate::core::session::caller::with_mut(SessionState::record_command);
377}
378
379/// Increment the tool-call counter and flush buffered telemetry every
380/// [`PERIODIC_FLUSH_INTERVAL`] calls. The MCP daemon is long-lived;
381/// without periodic flush, counters like `compressed_cache_hit` only
382/// appear in `auto_mode_sources.json` after the process exits.
383pub(crate) fn maybe_periodic_flush() {
384    let count = TOOL_CALL_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
385    if count.is_multiple_of(PERIODIC_FLUSH_INTERVAL) {
386        let _ = std::thread::spawn(flush_all);
387    }
388}
389
390/// Flush every buffered telemetry sink to disk.
391///
392/// The long-lived MCP daemon flushes these once at shutdown
393/// (`cli/dispatch/server.rs`). Single-shot CLI commands — and the shadow-mode
394/// hook subprocesses that spawn `lean-ctx read`/`grep` — exit immediately, so
395/// without this the buffered heatmap and threshold
396/// writes are silently lost the moment the process ends: `lean-ctx heatmap`
397/// stays empty and `lean-ctx gain` reports nothing for compressed reads (#550).
398///
399/// Centralized so the daemon shutdown, the parent watchdog and every CLI tool
400/// command flush the *exact same* set — the historical per-arm copies had
401/// drifted (the `read` arm flushed only `stats`, the `-c` arm four sinks, the
402/// daemon nine), which is precisely how the gap went unnoticed.
403pub fn flush_all() {
404    stats::flush();
405    heatmap::flush();
406    crate::core::path_mode_memory::flush();
407    crate::core::grammar_usage::flush();
408    crate::core::auto_mode_resolver::flush_sources();
409    crate::core::edit_quality::flush();
410    crate::core::edit_metering::flush();
411}
412
413/// [`flush_all`] for a process that is about to exit: first commits the
414/// provisional bounces still waiting for their edit window, so a bounce
415/// detected just before shutdown reaches the ledger and the path memory that
416/// `flush_all` then persists. Not part of `flush_all` itself, which also runs
417/// after single tool executions where committing early would book a
418/// read-before-edit as a bounce.
419pub fn flush_all_at_exit() {
420    crate::core::bounce_tracker::flush_pending();
421    flush_all();
422}
423
424/// The caller's own session id, if this runs inside an agent's request.
425fn caller_session_id() -> Option<String> {
426    crate::core::session::caller::with(|session| session.id.clone())
427}
428
429/// Project an aggressive-mode compression event into the OCLA CompressionProvider.
430/// Best-effort: silently drops if provider is unavailable or source_ref can't be
431/// constructed. This is the canonical production callsite for the compression capability.
432fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
433    use crate::core::ocla::OclaRegistry;
434    use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
435
436    let reg = OclaRegistry::global();
437    let source_ref = format!("file:{path}");
438    let request = CompressionRequest {
439        context: OclaRequestContext {
440            request_id: format!("cli-read-{}", path.len()),
441            session_id: caller_session_id().unwrap_or_default(),
442            agent_id: String::new(),
443            content_ref: source_ref.clone(),
444            tenant_id: None,
445            trace_id: "tr-unit".into(),
446            task_id: None,
447            parent_task_id: None,
448        },
449        source_ref,
450        source_tokens,
451        target_tokens: output_tokens,
452        quality_policy_ref: None,
453    };
454    let _ = reg.compression_provider.compress(request);
455}
456
457/// Project a caller-accounted read-savings observation into OCLA summaries.
458/// Best-effort: silently drops if the provider is unavailable. Canonical
459/// production callsite for the savings-evidence capability.
460fn project_ocla_savings(path: &str, original_tokens: u64, output_tokens: u64) {
461    use crate::core::ocla::OclaRegistry;
462    use crate::core::ocla::types::{OclaRequestContext, SavingsEvidence};
463
464    let context = OclaRequestContext {
465        request_id: format!("cli-read-{}", path.len()),
466        session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
467        agent_id: "lean-ctx".to_string(),
468        content_ref: format!("file:{path}"),
469        tenant_id: None,
470        trace_id: String::new(),
471        task_id: None,
472        parent_task_id: None,
473    };
474    let evidence = SavingsEvidence {
475        context,
476        original_tokens,
477        delivered_tokens: output_tokens,
478        quality_ref: None,
479        evidence_ref: format!("read:{path}:{original_tokens}:{output_tokens}"),
480    };
481    let _ = OclaRegistry::global()
482        .savings_ledger
483        .project_savings(evidence);
484}
485
486/// Truncate a shell command to a short label suitable for the context ledger.
487fn shell_ledger_label(command: &str) -> String {
488    let first_line = command.lines().next().unwrap_or(command);
489    let trimmed = first_line.trim();
490    if trimmed.len() <= 80 {
491        format!("$ {trimmed}")
492    } else {
493        format!("$ {}…", &trimmed[..trimmed.floor_char_boundary(77)])
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    // The record_* paths now drive process-global telemetry sinks (heatmap
502    // buffer, anomaly singleton) and read the data-dir env (#550), so
503    // every test here takes the shared isolation lock to serialize that state
504    // and keep its disk writes inside a throwaway dir.
505
506    #[test]
507    fn read_observation_does_not_invent_acceptance_or_quality() {
508        let _data = crate::core::data_dir::isolated_data_dir();
509        let outcome = read_observation("sample.rs", "full", "/project");
510        assert_eq!(outcome.accepted, None);
511        assert_eq!(outcome.quality_score_milli, None);
512        assert_eq!(outcome.context.content_ref, "file:sample.rs");
513        assert_eq!(outcome.outcome_ref.as_deref(), Some("read:/project:full"));
514    }
515
516    #[test]
517    fn record_file_read_does_not_panic_without_session() {
518        let _dir = crate::core::data_dir::isolated_data_dir();
519        record_file_read(
520            "/tmp/nonexistent.rs",
521            "full",
522            100,
523            50,
524            false,
525            std::time::Duration::from_millis(1),
526            "excerpt",
527        );
528    }
529
530    #[test]
531    fn record_search_does_not_panic_without_session() {
532        let _dir = crate::core::data_dir::isolated_data_dir();
533        record_search(
534            500,
535            200,
536            150,
537            "pattern",
538            "/tmp",
539            std::time::Duration::from_millis(1),
540            "matches",
541        );
542    }
543
544    #[test]
545    fn record_tree_does_not_panic_without_session() {
546        let _dir = crate::core::data_dir::isolated_data_dir();
547        record_tree(100, 80);
548    }
549
550    #[test]
551    fn record_shell_does_not_panic_without_session() {
552        let _dir = crate::core::data_dir::isolated_data_dir();
553        record_shell_command(500, 200);
554    }
555
556    #[test]
557    fn flush_all_is_idempotent_and_safe_without_state() {
558        let _dir = crate::core::data_dir::isolated_data_dir();
559        // Empty buffers: flushing must be a harmless no-op, and calling it twice
560        // (e.g. a CLI arm followed by an atexit path) must never panic.
561        flush_all();
562        flush_all();
563    }
564
565    #[test]
566    fn periodic_flush_counter_increments() {
567        TOOL_CALL_COUNT.store(0, Ordering::Relaxed);
568        maybe_periodic_flush();
569        assert_eq!(TOOL_CALL_COUNT.load(Ordering::Relaxed), 1);
570    }
571
572    #[test]
573    fn cli_read_persists_observation_sinks_to_disk() {
574        // #550 regression: a single-shot CLI read must leave the heatmap on
575        // disk, and no retired mode-predictor store. Task acceptance cannot be
576        // inferred from a read;
577        // the real-process read_observation_acceptance test guards historical
578        // feedback preservation and absence of fabricated completion records.
579        // The daemon used to be the only path that flushed them, so hook reads
580        // (`view`/`grep` →
581        // `lean-ctx read`) recorded nothing and `lean-ctx heatmap` stayed empty.
582        let dir = crate::core::data_dir::isolated_data_dir();
583        let file = dir.path().join("sample.rs");
584        std::fs::write(&file, "fn main() {\n    println!(\"hi\");\n}\n").unwrap();
585        let path = file.to_string_lossy();
586
587        record_file_read(
588            &path,
589            "full",
590            1000,
591            200,
592            false,
593            std::time::Duration::from_millis(2),
594            "sample.rs [3L]\nfn main() {}",
595        );
596        flush_all();
597
598        let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
599        let state = crate::core::paths::state_dir().expect("state dir");
600        assert!(
601            !data.join("mode_stats.json").exists(),
602            "a CLI read must not train the retired mode predictor"
603        );
604        assert!(
605            state.join("heatmap.json").exists(),
606            "heatmap must persist after a CLI read + flush"
607        );
608    }
609
610    #[test]
611    fn cli_read_records_context_ir_lineage() {
612        // #566: the MCP dispatcher records Context IR for every tool call, but the
613        // shadow-mode `lean-ctx read` subprocess used to skip it, so IR/ctx_proof
614        // exports were blind to compressed shadow reads. A single-shot CLI read
615        // must now persist exactly one IR item (disk-backed load→record→save).
616        let dir = crate::core::data_dir::isolated_data_dir();
617        let file = dir.path().join("ir_sample.rs");
618        std::fs::write(&file, "fn main() {}\n").unwrap();
619        let path = file.to_string_lossy();
620
621        record_file_read(
622            &path,
623            "full",
624            1000,
625            200,
626            false,
627            std::time::Duration::from_millis(3),
628            "ir_sample.rs [1L]\nfn main() {}",
629        );
630
631        let ir = ContextIrV1::load();
632        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
633        let item = &ir.items[0];
634        assert_eq!(item.source.tool, "ctx_read");
635        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
636        assert!(
637            item.source
638                .path
639                .as_deref()
640                .unwrap_or("")
641                .ends_with("ir_sample.rs"),
642            "IR records the read path, got {:?}",
643            item.source.path
644        );
645        assert_eq!(item.source.pattern.as_deref(), Some("full"));
646        assert_eq!(item.input_tokens, 1000);
647        assert_eq!(item.output_tokens, 200);
648        assert!(item.duration_us > 0, "a real duration must be recorded");
649        assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
650    }
651
652    #[test]
653    fn cli_search_records_context_ir_lineage() {
654        // #566: the shadow-mode `grep` → `lean-ctx grep` path records IR too.
655        let _dir = crate::core::data_dir::isolated_data_dir();
656
657        record_search(
658            800,
659            500,
660            120,
661            "fn handle",
662            "src/",
663            std::time::Duration::from_millis(4),
664            "src/lib.rs:12: fn handle() {}",
665        );
666
667        let ir = ContextIrV1::load();
668        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
669        let item = &ir.items[0];
670        assert_eq!(item.source.tool, "ctx_search");
671        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
672        // Input is the raw matched-line estimate, not the modeled baseline.
673        assert_eq!(item.input_tokens, 500);
674        assert_eq!(item.output_tokens, 120);
675        assert!(item.duration_us > 0, "a real duration must be recorded");
676    }
677}