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