Skip to main content

tj_core/
session_id.rs

1//! Live Claude Code session id helpers.
2//!
3//! task-journal already *parses* session ids out of Claude Code
4//! transcripts (`session::parser`) — that is a passive, read-only
5//! lookup of someone else's identifier. This module is the other
6//! direction: additively stamping the live session id onto the events
7//! the journal itself emits (hooks + MCP tools), so downstream
8//! consumers can correlate those events with the originating session
9//! without time-window heuristics.
10//!
11//! Source order: hook payload field `session_id` → `CLAUDE_CODE_SESSION_ID`
12//! env var → `None`. `None` means standalone behaviour is unchanged —
13//! nothing is added to `meta`.
14
15use serde_json::Value;
16
17/// Pull `session_id` out of a Claude Code hook payload (or a pending-v2
18/// chunk, which carries the same field). Empty strings count as absent.
19pub fn session_id_from_payload(payload: &Value) -> Option<String> {
20    payload
21        .get("session_id")
22        .and_then(|s| s.as_str())
23        .filter(|s| !s.is_empty())
24        .map(str::to_string)
25}
26
27/// Read `CLAUDE_CODE_SESSION_ID` from the environment. Empty counts as absent.
28pub fn session_id_from_env() -> Option<String> {
29    std::env::var("CLAUDE_CODE_SESSION_ID")
30        .ok()
31        .filter(|s| !s.is_empty())
32}
33
34/// Resolve the live session id: hook payload first, env var as fallback.
35/// `None` when neither source provides one (standalone — caller adds nothing).
36pub fn live_session_id(payload: Option<&Value>) -> Option<String> {
37    payload
38        .and_then(session_id_from_payload)
39        .or_else(session_id_from_env)
40}
41
42/// Additively record `session_id` into a free-form `meta` value.
43///
44/// No-op when `sid` is `None` or `meta` is not a JSON object. Never
45/// overwrites or removes existing keys — additive by construction.
46pub fn stamp_session_id(meta: &mut Value, sid: Option<&str>) {
47    if let (Some(sid), Some(obj)) = (sid, meta.as_object_mut()) {
48        obj.insert("session_id".to_string(), Value::String(sid.to_string()));
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use serde_json::json;
56    use std::sync::Mutex;
57
58    // Serialises the env-touching tests — std env is process-global.
59    static ENV_LOCK: Mutex<()> = Mutex::new(());
60
61    #[test]
62    fn payload_session_id_extracted() {
63        let p = json!({"session_id": "abc-123", "hook_event_name": "PostToolUse"});
64        assert_eq!(session_id_from_payload(&p).as_deref(), Some("abc-123"));
65    }
66
67    #[test]
68    fn payload_empty_or_missing_is_none() {
69        assert_eq!(session_id_from_payload(&json!({"session_id": ""})), None);
70        assert_eq!(session_id_from_payload(&json!({})), None);
71        assert_eq!(session_id_from_payload(&Value::Null), None);
72    }
73
74    #[test]
75    fn stamp_adds_to_object_meta() {
76        let mut meta = json!({"title": "Goal"});
77        stamp_session_id(&mut meta, Some("s-1"));
78        assert_eq!(meta["session_id"], json!("s-1"));
79        assert_eq!(meta["title"], json!("Goal"));
80    }
81
82    #[test]
83    fn stamp_none_is_noop() {
84        let mut meta = json!({"title": "Goal"});
85        stamp_session_id(&mut meta, None);
86        assert!(meta.get("session_id").is_none());
87    }
88
89    #[test]
90    fn stamp_on_non_object_is_noop() {
91        let mut meta = Value::Null;
92        stamp_session_id(&mut meta, Some("s-1"));
93        assert_eq!(meta, Value::Null);
94    }
95
96    #[test]
97    fn live_payload_wins_over_env() {
98        let _g = ENV_LOCK.lock().unwrap();
99        std::env::set_var("CLAUDE_CODE_SESSION_ID", "from-env");
100        let p = json!({"session_id": "from-payload"});
101        assert_eq!(live_session_id(Some(&p)).as_deref(), Some("from-payload"));
102        std::env::remove_var("CLAUDE_CODE_SESSION_ID");
103    }
104
105    #[test]
106    fn live_falls_back_to_env() {
107        let _g = ENV_LOCK.lock().unwrap();
108        std::env::set_var("CLAUDE_CODE_SESSION_ID", "from-env");
109        let p = json!({"hook_event_name": "Stop"});
110        assert_eq!(live_session_id(Some(&p)).as_deref(), Some("from-env"));
111        assert_eq!(live_session_id(None).as_deref(), Some("from-env"));
112        std::env::remove_var("CLAUDE_CODE_SESSION_ID");
113    }
114
115    #[test]
116    fn live_none_when_no_source() {
117        let _g = ENV_LOCK.lock().unwrap();
118        std::env::remove_var("CLAUDE_CODE_SESSION_ID");
119        assert_eq!(live_session_id(None), None);
120        assert_eq!(live_session_id(Some(&json!({}))), None);
121    }
122}