1#[allow(clippy::wildcard_imports)]
6use super::*;
7
8pub 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 emit_dedicated_session_context(&input);
28
29 super::edit_health::maybe_emit(&input);
33
34 let Some(event) = parse_observe_event(&input) else {
35 return;
36 };
37 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 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 let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
71 let client_name = session_start_client_name(&v);
72 let summary = build_dedicated_session_context(client_name, cfg.shadow_mode, &profile);
73 emit_session_start_additional_context(&summary);
74 } else {
75 emit_session_start_additional_context(
79 "lean-ctx active: ALWAYS use ctx_* MCP tools instead of native equivalents.\n\
80 - ctx_read > native Read (cached, re-reads ~13 tokens vs full file; 10 modes incl. map/signatures)\n\
81 - ctx_search > native Grep (compact results, denied by hook)\n\
82 - ctx_shell > native Shell (95+ compression patterns)\n\
83 - ctx_glob > native Glob (denied by hook)\n\
84 - ctx_compose = orient FIRST (bundles search+read+symbols in one call)\n\
85 Native Read passes through for StrReplace internals only — never use it for exploration.\n\
86 Exclusive tools: ctx_compose, ctx_callgraph, ctx_knowledge, ctx_session.",
87 );
88 }
89}
90
91fn build_dedicated_session_context(
93 client_name: &str,
94 shadow: bool,
95 profile: &crate::core::tool_profiles::ToolProfile,
96) -> String {
97 let effective_shadow = shadow && client_is_hook_covered(client_name);
98 crate::core::rules_canonical::render(
99 effective_shadow,
100 crate::core::rules_canonical::Wrapper::Bare,
101 crate::core::config::CompressionLevel::Off,
102 profile,
103 )
104}
105
106fn client_is_hook_covered(client_name: &str) -> bool {
108 crate::core::home::resolve_home_dir()
109 .is_some_and(|home| crate::core::rules_channel::client_hook_covered(client_name, &home))
110}
111
112fn session_start_client_name(payload: &serde_json::Value) -> &'static str {
114 if payload.get("conversation_id").is_some() {
115 "cursor"
116 } else {
117 "claude"
118 }
119}
120
121fn session_start_honours_additional_context(v: &serde_json::Value) -> bool {
129 v.get("hook_event_name").and_then(|e| e.as_str()) == Some("SessionStart")
130}
131
132#[derive(serde::Serialize)]
133struct ObserveEvent {
134 ts: u64,
135 event_type: &'static str,
136 tokens: usize,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 tool_name: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 detail: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 content: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 model: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 conversation_id: Option<String>,
147}
148
149const MAX_CONTENT_CHARS: usize = 50_000;
150
151fn parse_observe_event(input: &str) -> Option<ObserveEvent> {
152 let v: serde_json::Value = serde_json::from_str(input).ok()?;
153
154 let ts = std::time::SystemTime::now()
155 .duration_since(std::time::UNIX_EPOCH)
156 .unwrap_or_default()
157 .as_secs();
158
159 let model = v
160 .get("model")
161 .and_then(|m| m.as_str())
162 .filter(|m| !m.is_empty())
163 .map(String::from);
164 let conversation_id = v
165 .get("conversation_id")
166 .and_then(|c| c.as_str())
167 .filter(|c| !c.is_empty())
168 .map(String::from);
169
170 let transcript_path = v
171 .get("transcript_path")
172 .and_then(|t| t.as_str())
173 .filter(|t| !t.is_empty())
174 .map(String::from);
175
176 let session_id = v
181 .get("session_id")
182 .and_then(|s| s.as_str())
183 .filter(|s| !s.is_empty())
184 .map(String::from);
185
186 if let Some(ref m) = model {
187 persist_detected_model(m);
188 }
189 if let Some(ref tp) = transcript_path {
190 persist_transcript_path(tp, conversation_id.as_deref(), session_id.as_deref());
191 }
192
193 let mut event = detect_event_type(&v, ts)?;
194 event.model = model;
195 event.conversation_id = conversation_id;
196 Some(event)
197}
198
199fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
200 if let Some(result) = v.get("toolResult") {
205 let tool = super::payload::resolve_tool_name(v).unwrap_or_else(|| "unknown".to_string());
206 let args = super::payload::resolve_tool_args(v);
207 let command = args
208 .as_ref()
209 .and_then(|a| a.get("command"))
210 .and_then(|c| c.as_str());
211 let result_text = result
212 .get("textResultForLlm")
213 .and_then(|t| t.as_str())
214 .map_or_else(|| result.to_string(), String::from);
215 let tokens = result_text.len() / 4;
216 let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
217 let event_type = if is_lctx {
218 "mcp_call"
219 } else if command.is_some() {
220 "shell"
221 } else {
222 "native_tool"
223 };
224 let content = match command {
225 Some(cmd) => format!("$ {cmd}\n{result_text}"),
226 None => result_text,
227 };
228 return Some(ObserveEvent {
229 ts,
230 event_type,
231 tokens,
232 tool_name: Some(tool),
233 detail: command.map(|c| truncate_str(c, 80)),
234 content: Some(cap_content(&content)),
235 model: None,
236 conversation_id: None,
237 });
238 }
239
240 if let Some(result) = v
241 .get("result_json")
242 .or_else(|| v.get("result"))
243 .or_else(|| v.get("tool_response"))
244 .or_else(|| v.get("tool_output"))
245 {
246 let tool = v
247 .get("tool_name")
248 .and_then(|t| t.as_str())
249 .unwrap_or("unknown");
250 let tokens = estimate_tokens_json(result);
251 let content_str = match result {
252 serde_json::Value::String(s) => s.clone(),
253 other => other.to_string(),
254 };
255 return Some(ObserveEvent {
256 ts,
257 event_type: "mcp_call",
258 tokens,
259 tool_name: Some(tool.to_string()),
260 detail: v
261 .get("server_name")
262 .and_then(|s| s.as_str())
263 .map(String::from),
264 content: Some(cap_content(&content_str)),
265 model: None,
266 conversation_id: None,
267 });
268 }
269
270 if let Some(output) = v.get("output") {
271 let cmd = v
272 .get("command")
273 .and_then(|c| c.as_str())
274 .unwrap_or("")
275 .to_string();
276 let tokens = estimate_tokens_value(output);
277 let out_str = match output {
278 serde_json::Value::String(s) => s.clone(),
279 other => other.to_string(),
280 };
281 return Some(ObserveEvent {
282 ts,
283 event_type: "shell",
284 tokens,
285 tool_name: None,
286 detail: Some(truncate_str(&cmd, 80)),
287 content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
288 model: None,
289 conversation_id: None,
290 });
291 }
292
293 if v.get("content").is_some() && v.get("file_path").is_some() {
294 let path = v
295 .get("file_path")
296 .and_then(|p| p.as_str())
297 .unwrap_or("")
298 .to_string();
299 let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
300 let tokens = file_content.len() / 4;
301 return Some(ObserveEvent {
302 ts,
303 event_type: "file_read",
304 tokens,
305 tool_name: None,
306 detail: Some(truncate_str(&path, 120)),
307 content: Some(cap_content(file_content)),
308 model: None,
309 conversation_id: None,
310 });
311 }
312
313 if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
314 let has_duration = v.get("duration_ms").is_some();
315 let event_type = if has_duration {
316 "thinking"
317 } else {
318 "agent_response"
319 };
320 let tokens = text.len() / 4;
321 return Some(ObserveEvent {
322 ts,
323 event_type,
324 tokens,
325 tool_name: None,
326 detail: None,
327 content: Some(cap_content(text)),
328 model: None,
329 conversation_id: None,
330 });
331 }
332
333 if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
334 let tokens = prompt.len() / 4;
335 let mut full = prompt.to_string();
336 if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array())
337 && !attachments.is_empty()
338 {
339 full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
340 for att in attachments {
341 if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
342 full.push_str(&format!("\n - {name}"));
343 }
344 }
345 }
346 return Some(ObserveEvent {
347 ts,
348 event_type: "user_message",
349 tokens,
350 tool_name: None,
351 detail: v
352 .get("attachments")
353 .and_then(|a| a.as_array())
354 .map(|a| format!("{} attachments", a.len())),
355 content: Some(cap_content(&full)),
356 model: None,
357 conversation_id: None,
358 });
359 }
360
361 if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
362 let tool = v
363 .get("tool_name")
364 .and_then(|t| t.as_str())
365 .unwrap_or("unknown")
366 .to_string();
367 let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
368 let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
369 let input_str = v
370 .get("tool_input")
371 .map(std::string::ToString::to_string)
372 .unwrap_or_default();
373 return Some(ObserveEvent {
374 ts,
375 event_type: if is_lctx { "mcp_call" } else { "native_tool" },
376 tokens,
377 tool_name: Some(tool),
378 detail: None,
379 content: if input_str.is_empty() {
380 None
381 } else {
382 Some(cap_content(&input_str))
383 },
384 model: None,
385 conversation_id: None,
386 });
387 }
388
389 let is_compaction = v.get("compaction").is_some()
399 || v.get("messages_count").is_some()
400 || v.get("hook_event_name")
401 .and_then(|e| e.as_str())
402 .is_some_and(|e| e == "PreCompact")
403 || v.get("event")
404 .and_then(|e| e.as_str())
405 .is_some_and(|e| e == "compaction" || e == "compact");
406 if !is_compaction && v.get("session_id").is_some() {
407 return Some(ObserveEvent {
408 ts,
409 event_type: "session",
410 tokens: 0,
411 tool_name: None,
412 detail: v
413 .get("session_id")
414 .and_then(|s| s.as_str())
415 .map(String::from),
416 content: None,
417 model: None,
418 conversation_id: None,
419 });
420 }
421
422 if is_compaction {
423 return Some(ObserveEvent {
424 ts,
425 event_type: "compaction",
426 tokens: 0,
427 tool_name: None,
428 detail: None,
429 content: None,
430 model: None,
431 conversation_id: None,
432 });
433 }
434
435 None
436}
437
438fn estimate_tokens_json(v: &serde_json::Value) -> usize {
439 match v {
440 serde_json::Value::String(s) => s.len() / 4,
441 _ => v.to_string().len() / 4,
442 }
443}
444
445fn estimate_tokens_value(v: &serde_json::Value) -> usize {
446 match v {
447 serde_json::Value::String(s) => s.len() / 4,
448 _ => v.to_string().len() / 4,
449 }
450}
451
452fn persist_detected_model(model: &str) {
453 let m = model.to_lowercase();
454 let is_bg_model = m.contains("flash")
455 || m.contains("mini")
456 || m.contains("haiku")
457 || m.contains("fast")
458 || m.contains("nano")
459 || m.contains("small");
460 if is_bg_model {
461 return;
462 }
463
464 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
465 return;
466 };
467 let path = data_dir.join("detected_model.json");
468 let ts = std::time::SystemTime::now()
469 .duration_since(std::time::UNIX_EPOCH)
470 .unwrap_or_default()
471 .as_secs();
472 let window = model_context_window(model);
473 let payload = serde_json::json!({
474 "model": model,
475 "window_size": window,
476 "detected_at": ts,
477 });
478 if let Ok(json) = serde_json::to_string_pretty(&payload) {
479 let tmp = path.with_extension("tmp");
480 if std::fs::write(&tmp, &json).is_ok() {
481 let _ = std::fs::rename(&tmp, &path);
482 }
483 }
484}
485
486pub fn model_context_window(model: &str) -> usize {
487 crate::core::model_registry::context_window_for_model(model)
488}
489
490pub fn load_detected_model() -> Option<(String, usize)> {
491 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
492 let path = data_dir.join("detected_model.json");
493 let content = std::fs::read_to_string(&path).ok()?;
494 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
495 let model = v.get("model")?.as_str()?.to_string();
496 let window = v.get("window_size")?.as_u64()? as usize;
497 let detected_at = v.get("detected_at")?.as_u64()?;
498 let now = std::time::SystemTime::now()
499 .duration_since(std::time::UNIX_EPOCH)
500 .unwrap_or_default()
501 .as_secs();
502 if now.saturating_sub(detected_at) > 7200 {
503 return None;
504 }
505 Some((model, window))
506}
507
508fn persist_transcript_path(path: &str, conversation_id: Option<&str>, session_id: Option<&str>) {
509 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
510 return;
511 };
512 let meta_path = data_dir.join("active_transcript.json");
513 let ts = std::time::SystemTime::now()
514 .duration_since(std::time::UNIX_EPOCH)
515 .unwrap_or_default()
516 .as_secs();
517 let payload = serde_json::json!({
518 "transcript_path": path,
519 "conversation_id": conversation_id,
520 "session_id": session_id,
521 "updated_at": ts,
522 });
523 if let Ok(json) = serde_json::to_string_pretty(&payload) {
524 let tmp = meta_path.with_extension("tmp");
525 if std::fs::write(&tmp, &json).is_ok() {
526 let _ = std::fs::rename(&tmp, &meta_path);
527 }
528 }
529}
530
531pub fn load_active_transcript() -> Option<(String, Option<String>)> {
532 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
533 let path = data_dir.join("active_transcript.json");
534 let content = std::fs::read_to_string(&path).ok()?;
535 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
536 let tp = v.get("transcript_path")?.as_str()?.to_string();
537 let conv = v
541 .get("conversation_id")
542 .and_then(|c| c.as_str())
543 .or_else(|| v.get("session_id").and_then(|s| s.as_str()))
544 .map(String::from);
545 let updated = v.get("updated_at")?.as_u64()?;
546 let now = std::time::SystemTime::now()
547 .duration_since(std::time::UNIX_EPOCH)
548 .unwrap_or_default()
549 .as_secs();
550 if now.saturating_sub(updated) > 7200 {
551 return None;
552 }
553 Some((tp, conv))
554}
555
556fn cap_content(s: &str) -> String {
557 if s.len() <= MAX_CONTENT_CHARS {
558 s.to_string()
559 } else {
560 let truncated = safe_truncate(s, MAX_CONTENT_CHARS);
561 format!("{}…\n\n[truncated: {} total chars]", truncated, s.len())
562 }
563}
564
565fn truncate_str(s: &str, max: usize) -> String {
566 if s.len() <= max {
567 s.to_string()
568 } else {
569 format!("{}...", safe_truncate(s, max))
570 }
571}
572
573fn safe_truncate(s: &str, max: usize) -> &str {
575 if max >= s.len() {
576 return s;
577 }
578 let mut end = max;
579 while end > 0 && !s.is_char_boundary(end) {
580 end -= 1;
581 }
582 &s[..end]
583}
584
585fn append_radar_event(event: &ObserveEvent) {
586 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
587 return;
588 };
589 let radar_path = data_dir.join("context_radar.jsonl");
590
591 if event.event_type == "session"
592 && let Ok(meta) = std::fs::metadata(&radar_path)
593 {
594 const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; if meta.len() > MAX_RADAR_SIZE {
596 let prev = data_dir.join("context_radar.prev.jsonl");
597 let _ = std::fs::rename(&radar_path, &prev);
598 }
599 }
600
601 let Ok(line) = serde_json::to_string(event) else {
602 return;
603 };
604
605 use std::fs::OpenOptions;
606 use std::io::Write;
607 if let Ok(mut f) = OpenOptions::new()
608 .create(true)
609 .append(true)
610 .open(&radar_path)
611 {
612 let _ = writeln!(f, "{line}");
613 }
614
615 if event.event_type == "compaction" {
618 write_compaction_marker(&data_dir, event.ts);
619 }
620}
621
622fn write_compaction_marker(data_dir: &std::path::Path, ts: u64) {
625 use std::io::Write;
626 let marker_path = data_dir.join("last_compaction.json");
627 let tmp_path = data_dir.join("last_compaction.json.tmp");
628 let payload = format!(r#"{{"ts":{ts}}}"#);
629 let Ok(mut f) = std::fs::File::create(&tmp_path) else {
630 return;
631 };
632 if f.write_all(payload.as_bytes()).is_err() || f.sync_all().is_err() {
633 let _ = std::fs::remove_file(&tmp_path);
634 return;
635 }
636 drop(f);
637 let _ = std::fs::rename(&tmp_path, &marker_path);
638}
639
640#[must_use]
648pub fn radar_event_count() -> usize {
649 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
650 return 0;
651 };
652 let Ok(file) = std::fs::File::open(data_dir.join("context_radar.jsonl")) else {
653 return 0;
654 };
655 use std::io::{BufRead, BufReader};
656 BufReader::new(file)
657 .lines()
658 .map_while(Result::ok)
659 .filter(|l| !l.trim().is_empty())
660 .count()
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
668 fn detect_event_type_tool_response_is_mcp_call() {
669 let v = serde_json::json!({
670 "tool_name": "ctx_read",
671 "tool_response": "file contents here"
672 });
673 let event = detect_event_type(&v, 1000).unwrap();
674 assert_eq!(event.event_type, "mcp_call");
675 }
676
677 #[test]
678 fn detect_event_type_tool_output_is_mcp_call() {
679 let v = serde_json::json!({
680 "tool_name": "ctx_search",
681 "tool_output": "search results"
682 });
683 let event = detect_event_type(&v, 1000).unwrap();
684 assert_eq!(event.event_type, "mcp_call");
685 }
686
687 #[test]
688 fn detect_event_type_ctx_prefix_is_mcp_call() {
689 let v = serde_json::json!({
690 "tool_name": "ctx_read",
691 "tool_input": {"path": "src/main.rs"}
692 });
693 let event = detect_event_type(&v, 1000).unwrap();
694 assert_eq!(event.event_type, "mcp_call");
695 }
696
697 #[test]
698 fn detect_event_type_mcp_prefix_is_mcp_call() {
699 let v = serde_json::json!({
700 "tool_name": "mcp__lean-ctx__ctx_read",
701 "tool_input": {"path": "src/main.rs"}
702 });
703 let event = detect_event_type(&v, 1000).unwrap();
704 assert_eq!(event.event_type, "mcp_call");
705 }
706
707 #[test]
708 fn detect_event_type_native_read_is_native_tool() {
709 let v = serde_json::json!({
710 "tool_name": "Read",
711 "tool_input": {"path": "src/main.rs"}
712 });
713 let event = detect_event_type(&v, 1000).unwrap();
714 assert_eq!(event.event_type, "native_tool");
715 }
716
717 #[test]
718 fn detect_event_type_copilot_bash_posttooluse_is_shell() {
719 let v = serde_json::json!({
722 "toolName": "bash",
723 "toolArgs": "{\"command\":\"npm test\"}",
724 "toolResult": {
725 "resultType": "success",
726 "textResultForLlm": "All tests passed (15/15)"
727 }
728 });
729 let event = detect_event_type(&v, 1000).unwrap();
730 assert_eq!(event.event_type, "shell");
731 assert_eq!(event.tool_name.as_deref(), Some("bash"));
732 assert_eq!(event.detail.as_deref(), Some("npm test"));
733 assert!(event.content.unwrap().contains("All tests passed"));
734 }
735
736 #[test]
737 fn detect_event_type_copilot_ctx_tool_is_mcp_call() {
738 let v = serde_json::json!({
739 "toolName": "ctx_read",
740 "toolArgs": "{\"path\":\"src/main.rs\"}",
741 "toolResult": { "textResultForLlm": "file contents" }
742 });
743 let event = detect_event_type(&v, 1000).unwrap();
744 assert_eq!(event.event_type, "mcp_call");
745 assert_eq!(event.tool_name.as_deref(), Some("ctx_read"));
746 }
747
748 #[test]
749 fn detect_event_type_result_json_is_mcp_call() {
750 let v = serde_json::json!({
751 "tool_name": "ctx_read",
752 "result_json": {"content": "..."}
753 });
754 let event = detect_event_type(&v, 1000).unwrap();
755 assert_eq!(event.event_type, "mcp_call");
756 }
757
758 #[test]
762 fn detect_event_type_claude_precompact_is_compaction() {
763 let v = serde_json::json!({
764 "session_id": "abc123",
765 "transcript_path": "/Users/u/.claude/projects/x/abc123.jsonl",
766 "cwd": "/Users/u/project",
767 "hook_event_name": "PreCompact",
768 "trigger": "auto",
769 "custom_instructions": ""
770 });
771 let event = detect_event_type(&v, 1000).unwrap();
772 assert_eq!(event.event_type, "compaction");
773 }
774
775 #[test]
776 fn detect_event_type_plain_session_event_still_session() {
777 let v = serde_json::json!({
778 "session_id": "abc123",
779 "hook_event_name": "SessionStart"
780 });
781 let event = detect_event_type(&v, 1000).unwrap();
782 assert_eq!(event.event_type, "session");
783 }
784
785 #[test]
786 fn session_start_honoured_for_claude_payload() {
787 let v = serde_json::json!({
789 "hook_event_name": "SessionStart",
790 "session_id": "abc123",
791 "source": "startup"
792 });
793 assert!(session_start_honours_additional_context(&v));
794 }
795
796 #[test]
797 fn session_start_honoured_for_cursor_payload() {
798 let v = serde_json::json!({
800 "hook_event_name": "SessionStart",
801 "conversation_id": "0e1f4ed8-d858-4557-9fc5-6cbf5298eb8b",
802 "model": "claude-opus"
803 });
804 assert!(session_start_honours_additional_context(&v));
805 }
806
807 #[test]
808 fn claude_code_gets_full_mapping_not_shadow_minimal() {
809 let profile = crate::core::tool_profiles::ToolProfile::Standard;
810 let content = build_dedicated_session_context("claude", true, &profile);
811
812 assert!(
813 content.contains("ctx_read"),
814 "Claude must get ctx_read mapping"
815 );
816 assert!(
817 !content.contains("shadow mode"),
818 "Claude must not get shadow-minimal rules"
819 );
820 }
821
822 #[test]
823 fn session_start_skipped_for_non_session_event() {
824 let v = serde_json::json!({ "hook_event_name": "PreToolUse", "session_id": "x" });
825 assert!(!session_start_honours_additional_context(&v));
826 }
827
828 #[test]
832 fn session_id_is_used_as_scope_id_when_no_conversation_id() {
833 let _dir = crate::core::data_dir::isolated_data_dir();
834 persist_transcript_path("/tmp/x/abc.jsonl", None, Some("sess-abc"));
835 let (_, conv) = load_active_transcript().expect("marker just written");
836 assert_eq!(conv.as_deref(), Some("sess-abc"));
837 }
838
839 #[test]
841 fn conversation_id_wins_over_session_id() {
842 let _dir = crate::core::data_dir::isolated_data_dir();
843 persist_transcript_path("/tmp/x/abc.jsonl", Some("conv-1"), Some("sess-abc"));
844 let (_, conv) = load_active_transcript().expect("marker just written");
845 assert_eq!(conv.as_deref(), Some("conv-1"));
846 }
847}