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    // `flush_all` puts off a heat map saved within the last 30 s.
423    heatmap::flush_now();
424    // Observations a serving process handed to the detached writer, and its
425    // published request snapshot (#2196, #2197).
426    crate::core::request_ledger::finish_serving(std::time::Duration::from_secs(5));
427}
428
429/// The caller's own session id, if this runs inside an agent's request.
430fn caller_session_id() -> Option<String> {
431    crate::core::session::caller::with(|session| session.id.clone())
432}
433
434/// Project an aggressive-mode compression event into the OCLA CompressionProvider.
435/// Best-effort: silently drops if provider is unavailable or source_ref can't be
436/// constructed. This is the canonical production callsite for the compression capability.
437fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
438    use crate::core::ocla::OclaRegistry;
439    use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
440
441    let reg = OclaRegistry::global();
442    let source_ref = format!("file:{path}");
443    let request = CompressionRequest {
444        context: OclaRequestContext {
445            request_id: format!("cli-read-{}", path.len()),
446            session_id: caller_session_id().unwrap_or_default(),
447            agent_id: String::new(),
448            content_ref: source_ref.clone(),
449            tenant_id: None,
450            trace_id: "tr-unit".into(),
451            task_id: None,
452            parent_task_id: None,
453        },
454        source_ref,
455        source_tokens,
456        target_tokens: output_tokens,
457        quality_policy_ref: None,
458    };
459    let _ = reg.compression_provider.compress(request);
460}
461
462/// Project a caller-accounted read-savings observation into OCLA summaries.
463/// Best-effort: silently drops if the provider is unavailable. Canonical
464/// production callsite for the savings-evidence capability.
465fn project_ocla_savings(path: &str, original_tokens: u64, output_tokens: u64) {
466    use crate::core::ocla::OclaRegistry;
467    use crate::core::ocla::types::{OclaRequestContext, SavingsEvidence};
468
469    let context = OclaRequestContext {
470        request_id: format!("cli-read-{}", path.len()),
471        session_id: caller_session_id().unwrap_or_else(|| "cli-read".to_string()),
472        agent_id: "lean-ctx".to_string(),
473        content_ref: format!("file:{path}"),
474        tenant_id: None,
475        trace_id: String::new(),
476        task_id: None,
477        parent_task_id: None,
478    };
479    let evidence = SavingsEvidence {
480        context,
481        original_tokens,
482        delivered_tokens: output_tokens,
483        quality_ref: None,
484        evidence_ref: format!("read:{path}:{original_tokens}:{output_tokens}"),
485    };
486    let _ = OclaRegistry::global()
487        .savings_ledger
488        .project_savings(evidence);
489}
490
491/// Truncate a shell command to a short label suitable for the context ledger.
492fn shell_ledger_label(command: &str) -> String {
493    let first_line = command.lines().next().unwrap_or(command);
494    let trimmed = first_line.trim();
495    if trimmed.len() <= 80 {
496        format!("$ {trimmed}")
497    } else {
498        format!("$ {}…", &trimmed[..trimmed.floor_char_boundary(77)])
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    // The record_* paths now drive process-global telemetry sinks (heatmap
507    // buffer, anomaly singleton) and read the data-dir env (#550), so
508    // every test here takes the shared isolation lock to serialize that state
509    // and keep its disk writes inside a throwaway dir.
510
511    #[test]
512    fn read_observation_does_not_invent_acceptance_or_quality() {
513        let _data = crate::core::data_dir::isolated_data_dir();
514        let outcome = read_observation("sample.rs", "full", "/project");
515        assert_eq!(outcome.accepted, None);
516        assert_eq!(outcome.quality_score_milli, None);
517        assert_eq!(outcome.context.content_ref, "file:sample.rs");
518        assert_eq!(outcome.outcome_ref.as_deref(), Some("read:/project:full"));
519    }
520
521    #[test]
522    fn record_file_read_does_not_panic_without_session() {
523        let _dir = crate::core::data_dir::isolated_data_dir();
524        record_file_read(
525            "/tmp/nonexistent.rs",
526            "full",
527            100,
528            50,
529            false,
530            std::time::Duration::from_millis(1),
531            "excerpt",
532        );
533    }
534
535    #[test]
536    fn record_search_does_not_panic_without_session() {
537        let _dir = crate::core::data_dir::isolated_data_dir();
538        record_search(
539            500,
540            200,
541            150,
542            "pattern",
543            "/tmp",
544            std::time::Duration::from_millis(1),
545            "matches",
546        );
547    }
548
549    #[test]
550    fn record_tree_does_not_panic_without_session() {
551        let _dir = crate::core::data_dir::isolated_data_dir();
552        record_tree(100, 80);
553    }
554
555    #[test]
556    fn record_shell_does_not_panic_without_session() {
557        let _dir = crate::core::data_dir::isolated_data_dir();
558        record_shell_command(500, 200);
559    }
560
561    #[test]
562    fn flush_all_is_idempotent_and_safe_without_state() {
563        let _dir = crate::core::data_dir::isolated_data_dir();
564        // Empty buffers: flushing must be a harmless no-op, and calling it twice
565        // (e.g. a CLI arm followed by an atexit path) must never panic.
566        flush_all();
567        flush_all();
568    }
569
570    #[test]
571    fn periodic_flush_counter_increments() {
572        TOOL_CALL_COUNT.store(0, Ordering::Relaxed);
573        maybe_periodic_flush();
574        assert_eq!(TOOL_CALL_COUNT.load(Ordering::Relaxed), 1);
575    }
576
577    #[test]
578    fn cli_read_persists_observation_sinks_to_disk() {
579        // #550 regression: a single-shot CLI read must leave the heatmap on
580        // disk, and no retired mode-predictor store. Task acceptance cannot be
581        // inferred from a read;
582        // the real-process read_observation_acceptance test guards historical
583        // feedback preservation and absence of fabricated completion records.
584        // The daemon used to be the only path that flushed them, so hook reads
585        // (`view`/`grep` →
586        // `lean-ctx read`) recorded nothing and `lean-ctx heatmap` stayed empty.
587        let dir = crate::core::data_dir::isolated_data_dir();
588        let file = dir.path().join("sample.rs");
589        std::fs::write(&file, "fn main() {\n    println!(\"hi\");\n}\n").unwrap();
590        let path = file.to_string_lossy();
591
592        record_file_read(
593            &path,
594            "full",
595            1000,
596            200,
597            false,
598            std::time::Duration::from_millis(2),
599            "sample.rs [3L]\nfn main() {}",
600        );
601        flush_all();
602
603        let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
604        let state = crate::core::paths::state_dir().expect("state dir");
605        assert!(
606            !data.join("mode_stats.json").exists(),
607            "a CLI read must not train the retired mode predictor"
608        );
609        assert!(
610            state.join("heatmap.json").exists(),
611            "heatmap must persist after a CLI read + flush"
612        );
613    }
614
615    #[test]
616    fn cli_read_records_context_ir_lineage() {
617        // #566: the MCP dispatcher records Context IR for every tool call, but the
618        // shadow-mode `lean-ctx read` subprocess used to skip it, so IR/ctx_proof
619        // exports were blind to compressed shadow reads. A single-shot CLI read
620        // must now persist exactly one IR item (disk-backed load→record→save).
621        let dir = crate::core::data_dir::isolated_data_dir();
622        let file = dir.path().join("ir_sample.rs");
623        std::fs::write(&file, "fn main() {}\n").unwrap();
624        let path = file.to_string_lossy();
625
626        record_file_read(
627            &path,
628            "full",
629            1000,
630            200,
631            false,
632            std::time::Duration::from_millis(3),
633            "ir_sample.rs [1L]\nfn main() {}",
634        );
635
636        let ir = ContextIrV1::load();
637        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
638        let item = &ir.items[0];
639        assert_eq!(item.source.tool, "ctx_read");
640        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
641        assert!(
642            item.source
643                .path
644                .as_deref()
645                .unwrap_or("")
646                .ends_with("ir_sample.rs"),
647            "IR records the read path, got {:?}",
648            item.source.path
649        );
650        assert_eq!(item.source.pattern.as_deref(), Some("full"));
651        assert_eq!(item.input_tokens, 1000);
652        assert_eq!(item.output_tokens, 200);
653        assert!(item.duration_us > 0, "a real duration must be recorded");
654        assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
655    }
656
657    #[test]
658    fn cli_search_records_context_ir_lineage() {
659        // #566: the shadow-mode `grep` → `lean-ctx grep` path records IR too.
660        let _dir = crate::core::data_dir::isolated_data_dir();
661
662        record_search(
663            800,
664            500,
665            120,
666            "fn handle",
667            "src/",
668            std::time::Duration::from_millis(4),
669            "src/lib.rs:12: fn handle() {}",
670        );
671
672        let ir = ContextIrV1::load();
673        assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
674        let item = &ir.items[0];
675        assert_eq!(item.source.tool, "ctx_search");
676        assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
677        // Input is the raw matched-line estimate, not the modeled baseline.
678        assert_eq!(item.input_tokens, 500);
679        assert_eq!(item.output_tokens, 120);
680        assert!(item.duration_us > 0, "a real duration must be recorded");
681    }
682}