Skip to main content

lean_ctx/hook_handlers/
observe.rs

1//! Observe hook handler: records all IDE hook events for context awareness
2//! (event parsing, token estimation, model/transcript detection, radar log).
3//! Split out of `hook_handlers/mod.rs`; `use super::*` re-imports parent items.
4
5#[allow(clippy::wildcard_imports)]
6use super::*;
7
8// ---------------------------------------------------------------------------
9// Observe handler — records ALL hook events for context awareness
10// ---------------------------------------------------------------------------
11
12/// Unified observe handler for all IDE hook events.
13/// Reads JSON from stdin, normalizes to `ObserveEvent`, counts tokens,
14/// appends to `context_radar.jsonl`, and exits immediately.
15pub fn handle_observe() {
16    if is_disabled() {
17        return;
18    }
19    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
20        return;
21    };
22    // Dedicated rules-injection mode (#343): a Claude/Codex/CodeBuddy `SessionStart` hook
23    // injects the compact lean-ctx summary as `additionalContext` — the
24    // non-polluting stand-in for the (skipped) CLAUDE.md/CODEBUDDY.md/AGENTS.md block. All
25    // three agents register `hook observe` on SessionStart, so this is the single
26    // emit point (the Codex-specific handler stays silent in dedicated mode).
27    emit_dedicated_session_context(&input);
28
29    // Native-edit code-health notice (#1085): when the agent edits code with the
30    // host's native Edit/MultiEdit tools (bypassing ctx_edit's gate), surface an
31    // advisory complexity-regression notice via PostToolUse additionalContext.
32    super::edit_health::maybe_emit(&input);
33
34    let Some(event) = parse_observe_event(&input) else {
35        return;
36    };
37    // Compaction evicts the conversation the read-dedup stubs would point into
38    // (GL #1140): purge the session's re-read records so every file delivers
39    // full content again, mirroring the MCP-side compaction sync (GL #555).
40    if event.event_type == "compaction"
41        && let Ok(v) = serde_json::from_str::<serde_json::Value>(&input)
42        && let Some(session_id) = v.get("session_id").and_then(|s| s.as_str())
43    {
44        super::read_dedup::purge_session(session_id);
45    }
46    append_radar_event(&event);
47
48    // Output-echo analysis (#501): measure how much of the agent's reply
49    // re-quotes content lean-ctx already delivered, and feed the adaptive
50    // mode policy with an automatic feedback event.
51    if event.event_type == "agent_response"
52        && let Some(text) = event.content.as_deref()
53    {
54        crate::core::output_echo::analyze_and_record(text);
55    }
56}
57
58fn emit_dedicated_session_context(input: &str) {
59    let Ok(v) = serde_json::from_str::<serde_json::Value>(input) else {
60        return;
61    };
62    if !session_start_honours_additional_context(&v) {
63        return;
64    }
65    let cfg = crate::core::config::Config::load();
66
67    if cfg.dedicated_session_context_active() {
68        // Full Bare rules for dedicated-mode hosts (Claude Code, Codex, CodeBuddy)
69        // where the static rules file is skipped.
70        let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
71        let client_name = session_start_client_name(&v);
72        let summary = build_dedicated_session_context(client_name, cfg.shadow_mode, &profile);
73        emit_session_start_additional_context(&summary);
74    } else {
75        // Short reinforcement nudge for shared-mode hosts (Cursor) that already
76        // have static rules but benefit from in-conversation emphasis on exclusive
77        // tools. Models weight in-conversation context above static instructions.
78        emit_session_start_additional_context(
79            "lean-ctx active: ALWAYS use ctx_* MCP tools instead of native equivalents.\n\
80             - ctx_read > native Read (cached, re-reads ~13 tokens vs full file; 10 modes incl. map/signatures)\n\
81             - ctx_search > native Grep (compact results, denied by hook)\n\
82             - ctx_shell > native Shell (95+ compression patterns)\n\
83             - ctx_glob > native Glob (denied by hook)\n\
84             - ctx_compose = orient FIRST (bundles search+read+symbols in one call)\n\
85             Native Read passes through for StrReplace internals only — never use it for exploration.\n\
86             Exclusive tools: ctx_compose, ctx_callgraph, ctx_knowledge, ctx_session.",
87        );
88    }
89}
90
91/// Builds dedicated SessionStart rules, using shadow-minimal only when hooks cover native tools.
92fn build_dedicated_session_context(
93    client_name: &str,
94    shadow: bool,
95    profile: &crate::core::tool_profiles::ToolProfile,
96) -> String {
97    let effective_shadow = shadow && client_is_hook_covered(client_name);
98    crate::core::rules_canonical::render(
99        effective_shadow,
100        crate::core::rules_canonical::Wrapper::Bare,
101        crate::core::config::CompressionLevel::Off,
102        profile,
103    )
104}
105
106/// Identifies hook coverage from the installed rules-channel configuration.
107fn client_is_hook_covered(client_name: &str) -> bool {
108    crate::core::home::resolve_home_dir()
109        .is_some_and(|home| crate::core::rules_channel::client_hook_covered(client_name, &home))
110}
111
112/// Maps SessionStart payload shape to the only potentially hook-covered client, Cursor.
113fn session_start_client_name(payload: &serde_json::Value) -> &'static str {
114    if payload.get("conversation_id").is_some() {
115        "cursor"
116    } else {
117        "claude"
118    }
119}
120
121/// True for SessionStart payloads from hosts that honour `additionalContext`.
122///
123/// Cursor fixed SessionStart `additionalContext` support circa Q1 2026 —
124/// confirmed on the Cursor community forum as the only hook event where
125/// `additional_context` works end-to-end. The prior exclusion (#1031) is
126/// therefore removed: Cursor sessions now receive the same dedicated rules
127/// reinforcement as Claude/Codex/CodeBuddy.
128fn session_start_honours_additional_context(v: &serde_json::Value) -> bool {
129    v.get("hook_event_name").and_then(|e| e.as_str()) == Some("SessionStart")
130}
131
132#[derive(serde::Serialize)]
133struct ObserveEvent {
134    ts: u64,
135    event_type: &'static str,
136    tokens: usize,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    tool_name: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    detail: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    content: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    model: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    conversation_id: Option<String>,
147}
148
149const MAX_CONTENT_CHARS: usize = 50_000;
150
151fn parse_observe_event(input: &str) -> Option<ObserveEvent> {
152    let v: serde_json::Value = serde_json::from_str(input).ok()?;
153
154    let ts = std::time::SystemTime::now()
155        .duration_since(std::time::UNIX_EPOCH)
156        .unwrap_or_default()
157        .as_secs();
158
159    let model = v
160        .get("model")
161        .and_then(|m| m.as_str())
162        .filter(|m| !m.is_empty())
163        .map(String::from);
164    let conversation_id = v
165        .get("conversation_id")
166        .and_then(|c| c.as_str())
167        .filter(|c| !c.is_empty())
168        .map(String::from);
169
170    let transcript_path = v
171        .get("transcript_path")
172        .and_then(|t| t.as_str())
173        .filter(|t| !t.is_empty())
174        .map(String::from);
175
176    // Claude Code / Codex / CodeBuddy carry a per-session `session_id` but no
177    // `conversation_id`. Persisting it lets the read-cache scope a session's
178    // deliveries the same way Cursor's `conversation_id` does, so a new session
179    // never inherits a prior one's `[unchanged]` stubs (#1004).
180    let session_id = v
181        .get("session_id")
182        .and_then(|s| s.as_str())
183        .filter(|s| !s.is_empty())
184        .map(String::from);
185
186    if let Some(ref m) = model {
187        persist_detected_model(m);
188    }
189    if let Some(ref tp) = transcript_path {
190        persist_transcript_path(tp, conversation_id.as_deref(), session_id.as_deref());
191    }
192
193    let mut event = detect_event_type(&v, ts)?;
194    event.model = model;
195    event.conversation_id = conversation_id;
196    Some(event)
197}
198
199fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
200    // GitHub Copilot CLI postToolUse: camelCase `toolName` + `toolArgs`
201    // (JSON-encoded string) + `toolResult`. None of the snake_case branches
202    // below match this shape, so without a dedicated arm Copilot telemetry
203    // (heatmap, token savings, radar) is silently dropped (#551).
204    if let Some(result) = v.get("toolResult") {
205        let tool = super::payload::resolve_tool_name(v).unwrap_or_else(|| "unknown".to_string());
206        let args = super::payload::resolve_tool_args(v);
207        let command = args
208            .as_ref()
209            .and_then(|a| a.get("command"))
210            .and_then(|c| c.as_str());
211        let result_text = result
212            .get("textResultForLlm")
213            .and_then(|t| t.as_str())
214            .map_or_else(|| result.to_string(), String::from);
215        let tokens = result_text.len() / 4;
216        let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
217        let event_type = if is_lctx {
218            "mcp_call"
219        } else if command.is_some() {
220            "shell"
221        } else {
222            "native_tool"
223        };
224        let content = match command {
225            Some(cmd) => format!("$ {cmd}\n{result_text}"),
226            None => result_text,
227        };
228        return Some(ObserveEvent {
229            ts,
230            event_type,
231            tokens,
232            tool_name: Some(tool),
233            detail: command.map(|c| truncate_str(c, 80)),
234            content: Some(cap_content(&content)),
235            model: None,
236            conversation_id: None,
237        });
238    }
239
240    if let Some(result) = v
241        .get("result_json")
242        .or_else(|| v.get("result"))
243        .or_else(|| v.get("tool_response"))
244        .or_else(|| v.get("tool_output"))
245    {
246        let tool = v
247            .get("tool_name")
248            .and_then(|t| t.as_str())
249            .unwrap_or("unknown");
250        let tokens = estimate_tokens_json(result);
251        let content_str = match result {
252            serde_json::Value::String(s) => s.clone(),
253            other => other.to_string(),
254        };
255        return Some(ObserveEvent {
256            ts,
257            event_type: "mcp_call",
258            tokens,
259            tool_name: Some(tool.to_string()),
260            detail: v
261                .get("server_name")
262                .and_then(|s| s.as_str())
263                .map(String::from),
264            content: Some(cap_content(&content_str)),
265            model: None,
266            conversation_id: None,
267        });
268    }
269
270    if let Some(output) = v.get("output") {
271        let cmd = v
272            .get("command")
273            .and_then(|c| c.as_str())
274            .unwrap_or("")
275            .to_string();
276        let tokens = estimate_tokens_value(output);
277        let out_str = match output {
278            serde_json::Value::String(s) => s.clone(),
279            other => other.to_string(),
280        };
281        return Some(ObserveEvent {
282            ts,
283            event_type: "shell",
284            tokens,
285            tool_name: None,
286            detail: Some(truncate_str(&cmd, 80)),
287            content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
288            model: None,
289            conversation_id: None,
290        });
291    }
292
293    if v.get("content").is_some() && v.get("file_path").is_some() {
294        let path = v
295            .get("file_path")
296            .and_then(|p| p.as_str())
297            .unwrap_or("")
298            .to_string();
299        let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
300        let tokens = file_content.len() / 4;
301        return Some(ObserveEvent {
302            ts,
303            event_type: "file_read",
304            tokens,
305            tool_name: None,
306            detail: Some(truncate_str(&path, 120)),
307            content: Some(cap_content(file_content)),
308            model: None,
309            conversation_id: None,
310        });
311    }
312
313    if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
314        let has_duration = v.get("duration_ms").is_some();
315        let event_type = if has_duration {
316            "thinking"
317        } else {
318            "agent_response"
319        };
320        let tokens = text.len() / 4;
321        return Some(ObserveEvent {
322            ts,
323            event_type,
324            tokens,
325            tool_name: None,
326            detail: None,
327            content: Some(cap_content(text)),
328            model: None,
329            conversation_id: None,
330        });
331    }
332
333    if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
334        let tokens = prompt.len() / 4;
335        let mut full = prompt.to_string();
336        if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array())
337            && !attachments.is_empty()
338        {
339            full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
340            for att in attachments {
341                if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
342                    full.push_str(&format!("\n  - {name}"));
343                }
344            }
345        }
346        return Some(ObserveEvent {
347            ts,
348            event_type: "user_message",
349            tokens,
350            tool_name: None,
351            detail: v
352                .get("attachments")
353                .and_then(|a| a.as_array())
354                .map(|a| format!("{} attachments", a.len())),
355            content: Some(cap_content(&full)),
356            model: None,
357            conversation_id: None,
358        });
359    }
360
361    if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
362        let tool = v
363            .get("tool_name")
364            .and_then(|t| t.as_str())
365            .unwrap_or("unknown")
366            .to_string();
367        let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
368        let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
369        let input_str = v
370            .get("tool_input")
371            .map(std::string::ToString::to_string)
372            .unwrap_or_default();
373        return Some(ObserveEvent {
374            ts,
375            event_type: if is_lctx { "mcp_call" } else { "native_tool" },
376            tokens,
377            tool_name: Some(tool),
378            detail: None,
379            content: if input_str.is_empty() {
380                None
381            } else {
382                Some(cap_content(&input_str))
383            },
384            model: None,
385            conversation_id: None,
386        });
387    }
388
389    // Claude Code emits `hook_event_name: "PreCompact"` (code.claude.com/docs/
390    // en/hooks); the generic `event`/`compaction` shapes cover other hosts.
391    // This check must run BEFORE the `session_id` catch-all below: every
392    // Claude hook payload carries `session_id` as a common field, so the
393    // compaction branch was unreachable for Claude — compactions were never
394    // recorded, `sync_if_compacted` never reset delivery flags, and
395    // post-compaction re-reads kept answering with "[unchanged]" stubs that
396    // pointed at context the host had already evicted (GL #555). Agents then
397    // fell back to native Read to recover the content.
398    let is_compaction = v.get("compaction").is_some()
399        || v.get("messages_count").is_some()
400        || v.get("hook_event_name")
401            .and_then(|e| e.as_str())
402            .is_some_and(|e| e == "PreCompact")
403        || v.get("event")
404            .and_then(|e| e.as_str())
405            .is_some_and(|e| e == "compaction" || e == "compact");
406    if !is_compaction && v.get("session_id").is_some() {
407        return Some(ObserveEvent {
408            ts,
409            event_type: "session",
410            tokens: 0,
411            tool_name: None,
412            detail: v
413                .get("session_id")
414                .and_then(|s| s.as_str())
415                .map(String::from),
416            content: None,
417            model: None,
418            conversation_id: None,
419        });
420    }
421
422    if is_compaction {
423        return Some(ObserveEvent {
424            ts,
425            event_type: "compaction",
426            tokens: 0,
427            tool_name: None,
428            detail: None,
429            content: None,
430            model: None,
431            conversation_id: None,
432        });
433    }
434
435    None
436}
437
438fn estimate_tokens_json(v: &serde_json::Value) -> usize {
439    match v {
440        serde_json::Value::String(s) => s.len() / 4,
441        _ => v.to_string().len() / 4,
442    }
443}
444
445fn estimate_tokens_value(v: &serde_json::Value) -> usize {
446    match v {
447        serde_json::Value::String(s) => s.len() / 4,
448        _ => v.to_string().len() / 4,
449    }
450}
451
452fn persist_detected_model(model: &str) {
453    let m = model.to_lowercase();
454    let is_bg_model = m.contains("flash")
455        || m.contains("mini")
456        || m.contains("haiku")
457        || m.contains("fast")
458        || m.contains("nano")
459        || m.contains("small");
460    if is_bg_model {
461        return;
462    }
463
464    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
465        return;
466    };
467    let path = data_dir.join("detected_model.json");
468    let ts = std::time::SystemTime::now()
469        .duration_since(std::time::UNIX_EPOCH)
470        .unwrap_or_default()
471        .as_secs();
472    let window = model_context_window(model);
473    let payload = serde_json::json!({
474        "model": model,
475        "window_size": window,
476        "detected_at": ts,
477    });
478    if let Ok(json) = serde_json::to_string_pretty(&payload) {
479        let tmp = path.with_extension("tmp");
480        if std::fs::write(&tmp, &json).is_ok() {
481            let _ = std::fs::rename(&tmp, &path);
482        }
483    }
484}
485
486pub fn model_context_window(model: &str) -> usize {
487    crate::core::model_registry::context_window_for_model(model)
488}
489
490pub fn load_detected_model() -> Option<(String, usize)> {
491    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
492    let path = data_dir.join("detected_model.json");
493    let content = std::fs::read_to_string(&path).ok()?;
494    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
495    let model = v.get("model")?.as_str()?.to_string();
496    let window = v.get("window_size")?.as_u64()? as usize;
497    let detected_at = v.get("detected_at")?.as_u64()?;
498    let now = std::time::SystemTime::now()
499        .duration_since(std::time::UNIX_EPOCH)
500        .unwrap_or_default()
501        .as_secs();
502    if now.saturating_sub(detected_at) > 7200 {
503        return None;
504    }
505    Some((model, window))
506}
507
508fn persist_transcript_path(path: &str, conversation_id: Option<&str>, session_id: Option<&str>) {
509    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
510        return;
511    };
512    let meta_path = data_dir.join("active_transcript.json");
513    let ts = std::time::SystemTime::now()
514        .duration_since(std::time::UNIX_EPOCH)
515        .unwrap_or_default()
516        .as_secs();
517    let payload = serde_json::json!({
518        "transcript_path": path,
519        "conversation_id": conversation_id,
520        "session_id": session_id,
521        "updated_at": ts,
522    });
523    if let Ok(json) = serde_json::to_string_pretty(&payload) {
524        let tmp = meta_path.with_extension("tmp");
525        if std::fs::write(&tmp, &json).is_ok() {
526            let _ = std::fs::rename(&tmp, &meta_path);
527        }
528    }
529}
530
531pub fn load_active_transcript() -> Option<(String, Option<String>)> {
532    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
533    let path = data_dir.join("active_transcript.json");
534    let content = std::fs::read_to_string(&path).ok()?;
535    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
536    let tp = v.get("transcript_path")?.as_str()?.to_string();
537    // Prefer Cursor's `conversation_id`; fall back to the host `session_id`
538    // (Claude Code / Codex / CodeBuddy) so the read-cache still has a per-session
539    // identity to scope stubs against (#1004).
540    let conv = v
541        .get("conversation_id")
542        .and_then(|c| c.as_str())
543        .or_else(|| v.get("session_id").and_then(|s| s.as_str()))
544        .map(String::from);
545    let updated = v.get("updated_at")?.as_u64()?;
546    let now = std::time::SystemTime::now()
547        .duration_since(std::time::UNIX_EPOCH)
548        .unwrap_or_default()
549        .as_secs();
550    if now.saturating_sub(updated) > 7200 {
551        return None;
552    }
553    Some((tp, conv))
554}
555
556fn cap_content(s: &str) -> String {
557    if s.len() <= MAX_CONTENT_CHARS {
558        s.to_string()
559    } else {
560        let truncated = safe_truncate(s, MAX_CONTENT_CHARS);
561        format!("{}…\n\n[truncated: {} total chars]", truncated, s.len())
562    }
563}
564
565fn truncate_str(s: &str, max: usize) -> String {
566    if s.len() <= max {
567        s.to_string()
568    } else {
569        format!("{}...", safe_truncate(s, max))
570    }
571}
572
573/// Truncate a string at a char boundary <= max bytes. Never panics on multi-byte UTF-8.
574fn safe_truncate(s: &str, max: usize) -> &str {
575    if max >= s.len() {
576        return s;
577    }
578    let mut end = max;
579    while end > 0 && !s.is_char_boundary(end) {
580        end -= 1;
581    }
582    &s[..end]
583}
584
585fn append_radar_event(event: &ObserveEvent) {
586    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
587        return;
588    };
589    let radar_path = data_dir.join("context_radar.jsonl");
590
591    if event.event_type == "session"
592        && let Ok(meta) = std::fs::metadata(&radar_path)
593    {
594        const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
595        if meta.len() > MAX_RADAR_SIZE {
596            let prev = data_dir.join("context_radar.prev.jsonl");
597            let _ = std::fs::rename(&radar_path, &prev);
598        }
599    }
600
601    let Ok(line) = serde_json::to_string(event) else {
602        return;
603    };
604
605    use std::fs::OpenOptions;
606    use std::io::Write;
607    if let Ok(mut f) = OpenOptions::new()
608        .create(true)
609        .append(true)
610        .open(&radar_path)
611    {
612        let _ = writeln!(f, "{line}");
613    }
614
615    // #808: write an atomic marker file so the MCP server can detect
616    // compactions reliably even when large events bury the radar entry.
617    if event.event_type == "compaction" {
618        write_compaction_marker(&data_dir, event.ts);
619    }
620}
621
622/// Atomically write `last_compaction.json` via temp-file + rename.
623/// The marker is a few bytes; no other event can displace it.
624fn write_compaction_marker(data_dir: &std::path::Path, ts: u64) {
625    use std::io::Write;
626    let marker_path = data_dir.join("last_compaction.json");
627    let tmp_path = data_dir.join("last_compaction.json.tmp");
628    let payload = format!(r#"{{"ts":{ts}}}"#);
629    let Ok(mut f) = std::fs::File::create(&tmp_path) else {
630        return;
631    };
632    if f.write_all(payload.as_bytes()).is_err() || f.sync_all().is_err() {
633        let _ = std::fs::remove_file(&tmp_path);
634        return;
635    }
636    drop(f);
637    let _ = std::fs::rename(&tmp_path, &marker_path);
638}
639
640/// Count the IDE-hook observe events recorded in `context_radar.jsonl`.
641///
642/// `watch` uses this to explain an empty live feed (#593): a non-zero count
643/// means IDE hooks ARE firing — lean-ctx is wired into the editor — even though
644/// no `ctx_*` MCP tool has been called yet. That distinguishes "the agent is
645/// using native tools instead of ctx_*" from "nothing is connected at all".
646/// Counts newline-delimited records; returns 0 when the file is absent.
647#[must_use]
648pub fn radar_event_count() -> usize {
649    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
650        return 0;
651    };
652    let Ok(file) = std::fs::File::open(data_dir.join("context_radar.jsonl")) else {
653        return 0;
654    };
655    use std::io::{BufRead, BufReader};
656    BufReader::new(file)
657        .lines()
658        .map_while(Result::ok)
659        .filter(|l| !l.trim().is_empty())
660        .count()
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn detect_event_type_tool_response_is_mcp_call() {
669        let v = serde_json::json!({
670            "tool_name": "ctx_read",
671            "tool_response": "file contents here"
672        });
673        let event = detect_event_type(&v, 1000).unwrap();
674        assert_eq!(event.event_type, "mcp_call");
675    }
676
677    #[test]
678    fn detect_event_type_tool_output_is_mcp_call() {
679        let v = serde_json::json!({
680            "tool_name": "ctx_search",
681            "tool_output": "search results"
682        });
683        let event = detect_event_type(&v, 1000).unwrap();
684        assert_eq!(event.event_type, "mcp_call");
685    }
686
687    #[test]
688    fn detect_event_type_ctx_prefix_is_mcp_call() {
689        let v = serde_json::json!({
690            "tool_name": "ctx_read",
691            "tool_input": {"path": "src/main.rs"}
692        });
693        let event = detect_event_type(&v, 1000).unwrap();
694        assert_eq!(event.event_type, "mcp_call");
695    }
696
697    #[test]
698    fn detect_event_type_mcp_prefix_is_mcp_call() {
699        let v = serde_json::json!({
700            "tool_name": "mcp__lean-ctx__ctx_read",
701            "tool_input": {"path": "src/main.rs"}
702        });
703        let event = detect_event_type(&v, 1000).unwrap();
704        assert_eq!(event.event_type, "mcp_call");
705    }
706
707    #[test]
708    fn detect_event_type_native_read_is_native_tool() {
709        let v = serde_json::json!({
710            "tool_name": "Read",
711            "tool_input": {"path": "src/main.rs"}
712        });
713        let event = detect_event_type(&v, 1000).unwrap();
714        assert_eq!(event.event_type, "native_tool");
715    }
716
717    #[test]
718    fn detect_event_type_copilot_bash_posttooluse_is_shell() {
719        // #551: Copilot CLI postToolUse — camelCase `toolName` + JSON-string
720        // `toolArgs` + `toolResult`. Was dropped before the fix; now recorded.
721        let v = serde_json::json!({
722            "toolName": "bash",
723            "toolArgs": "{\"command\":\"npm test\"}",
724            "toolResult": {
725                "resultType": "success",
726                "textResultForLlm": "All tests passed (15/15)"
727            }
728        });
729        let event = detect_event_type(&v, 1000).unwrap();
730        assert_eq!(event.event_type, "shell");
731        assert_eq!(event.tool_name.as_deref(), Some("bash"));
732        assert_eq!(event.detail.as_deref(), Some("npm test"));
733        assert!(event.content.unwrap().contains("All tests passed"));
734    }
735
736    #[test]
737    fn detect_event_type_copilot_ctx_tool_is_mcp_call() {
738        let v = serde_json::json!({
739            "toolName": "ctx_read",
740            "toolArgs": "{\"path\":\"src/main.rs\"}",
741            "toolResult": { "textResultForLlm": "file contents" }
742        });
743        let event = detect_event_type(&v, 1000).unwrap();
744        assert_eq!(event.event_type, "mcp_call");
745        assert_eq!(event.tool_name.as_deref(), Some("ctx_read"));
746    }
747
748    #[test]
749    fn detect_event_type_result_json_is_mcp_call() {
750        let v = serde_json::json!({
751            "tool_name": "ctx_read",
752            "result_json": {"content": "..."}
753        });
754        let event = detect_event_type(&v, 1000).unwrap();
755        assert_eq!(event.event_type, "mcp_call");
756    }
757
758    /// Real Claude Code PreCompact payload (code.claude.com/docs/en/hooks):
759    /// carries `session_id` like every Claude hook, so the compaction check
760    /// must win over the generic session catch-all (GL #555).
761    #[test]
762    fn detect_event_type_claude_precompact_is_compaction() {
763        let v = serde_json::json!({
764            "session_id": "abc123",
765            "transcript_path": "/Users/u/.claude/projects/x/abc123.jsonl",
766            "cwd": "/Users/u/project",
767            "hook_event_name": "PreCompact",
768            "trigger": "auto",
769            "custom_instructions": ""
770        });
771        let event = detect_event_type(&v, 1000).unwrap();
772        assert_eq!(event.event_type, "compaction");
773    }
774
775    #[test]
776    fn detect_event_type_plain_session_event_still_session() {
777        let v = serde_json::json!({
778            "session_id": "abc123",
779            "hook_event_name": "SessionStart"
780        });
781        let event = detect_event_type(&v, 1000).unwrap();
782        assert_eq!(event.event_type, "session");
783    }
784
785    #[test]
786    fn session_start_honoured_for_claude_payload() {
787        // Claude/Codex/CodeBuddy: hook_event_name + session_id, no conversation_id.
788        let v = serde_json::json!({
789            "hook_event_name": "SessionStart",
790            "session_id": "abc123",
791            "source": "startup"
792        });
793        assert!(session_start_honours_additional_context(&v));
794    }
795
796    #[test]
797    fn session_start_honoured_for_cursor_payload() {
798        // Cursor fixed SessionStart additionalContext ~Q1 2026 — now included.
799        let v = serde_json::json!({
800            "hook_event_name": "SessionStart",
801            "conversation_id": "0e1f4ed8-d858-4557-9fc5-6cbf5298eb8b",
802            "model": "claude-opus"
803        });
804        assert!(session_start_honours_additional_context(&v));
805    }
806
807    #[test]
808    fn claude_code_gets_full_mapping_not_shadow_minimal() {
809        let profile = crate::core::tool_profiles::ToolProfile::Standard;
810        let content = build_dedicated_session_context("claude", true, &profile);
811
812        assert!(
813            content.contains("ctx_read"),
814            "Claude must get ctx_read mapping"
815        );
816        assert!(
817            !content.contains("shadow mode"),
818            "Claude must not get shadow-minimal rules"
819        );
820    }
821
822    #[test]
823    fn session_start_skipped_for_non_session_event() {
824        let v = serde_json::json!({ "hook_event_name": "PreToolUse", "session_id": "x" });
825        assert!(!session_start_honours_additional_context(&v));
826    }
827
828    /// #1004: a Claude/Codex/CodeBuddy payload has no `conversation_id`, so the
829    /// persisted `session_id` must surface as the scope id — otherwise the read
830    /// cache sees `None` and a new session inherits the prior one's stubs.
831    #[test]
832    fn session_id_is_used_as_scope_id_when_no_conversation_id() {
833        let _dir = crate::core::data_dir::isolated_data_dir();
834        persist_transcript_path("/tmp/x/abc.jsonl", None, Some("sess-abc"));
835        let (_, conv) = load_active_transcript().expect("marker just written");
836        assert_eq!(conv.as_deref(), Some("sess-abc"));
837    }
838
839    /// Cursor's `conversation_id` still wins when both are present.
840    #[test]
841    fn conversation_id_wins_over_session_id() {
842        let _dir = crate::core::data_dir::isolated_data_dir();
843        persist_transcript_path("/tmp/x/abc.jsonl", Some("conv-1"), Some("sess-abc"));
844        let (_, conv) = load_active_transcript().expect("marker just written");
845        assert_eq!(conv.as_deref(), Some("conv-1"));
846    }
847}