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