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