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