Skip to main content

opensession_core/
session.rs

1use crate::trace::Session;
2
3pub const ATTR_CWD: &str = "cwd";
4pub const ATTR_WORKING_DIRECTORY: &str = "working_directory";
5pub const ATTR_SOURCE_PATH: &str = "source_path";
6pub const ATTR_SESSION_ROLE: &str = "session_role";
7pub const ATTR_PARENT_SESSION_ID: &str = "parent_session_id";
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SessionRole {
11    Primary,
12    Auxiliary,
13}
14
15fn attr_non_empty_str<'a>(session: &'a Session, key: &str) -> Option<&'a str> {
16    session
17        .context
18        .attributes
19        .get(key)
20        .and_then(|value| value.as_str())
21        .map(str::trim)
22        .filter(|value| !value.is_empty())
23}
24
25pub fn working_directory(session: &Session) -> Option<&str> {
26    attr_non_empty_str(session, ATTR_CWD)
27        .or_else(|| attr_non_empty_str(session, ATTR_WORKING_DIRECTORY))
28}
29
30pub fn source_path(session: &Session) -> Option<&str> {
31    attr_non_empty_str(session, ATTR_SOURCE_PATH)
32}
33
34pub fn session_role(session: &Session) -> SessionRole {
35    if let Some(raw_role) = attr_non_empty_str(session, ATTR_SESSION_ROLE) {
36        if raw_role.eq_ignore_ascii_case("auxiliary") {
37            return SessionRole::Auxiliary;
38        }
39        if raw_role.eq_ignore_ascii_case("primary") {
40            return SessionRole::Primary;
41        }
42    }
43
44    if !session.context.related_session_ids.is_empty() {
45        return SessionRole::Auxiliary;
46    }
47
48    if attr_non_empty_str(session, ATTR_PARENT_SESSION_ID).is_some() {
49        return SessionRole::Auxiliary;
50    }
51
52    SessionRole::Primary
53}
54
55pub fn is_auxiliary_session(session: &Session) -> bool {
56    session_role(session) == SessionRole::Auxiliary
57}
58
59pub fn build_git_storage_meta_json(session: &Session) -> Vec<u8> {
60    let role = match session_role(session) {
61        SessionRole::Primary => "primary",
62        SessionRole::Auxiliary => "auxiliary",
63    };
64
65    serde_json::to_vec_pretty(&serde_json::json!({
66        "session_id": session.session_id,
67        "title": session.context.title,
68        "tool": session.agent.tool,
69        "model": session.agent.model,
70        "session_role": role,
71        "stats": session.stats,
72    }))
73    .unwrap_or_default()
74}
75
76#[cfg(test)]
77mod tests {
78    use super::{
79        is_auxiliary_session, session_role, source_path, working_directory, SessionRole,
80        ATTR_PARENT_SESSION_ID, ATTR_SESSION_ROLE,
81    };
82    use crate::trace::{Agent, Session};
83    use serde_json::Value;
84
85    fn make_session() -> Session {
86        Session::new(
87            "s1".to_string(),
88            Agent {
89                provider: "openai".to_string(),
90                model: "gpt-5".to_string(),
91                tool: "codex".to_string(),
92                tool_version: None,
93            },
94        )
95    }
96
97    #[test]
98    fn working_directory_prefers_cwd() {
99        let mut session = make_session();
100        session.context.attributes.insert(
101            "cwd".to_string(),
102            Value::String("/repo/preferred".to_string()),
103        );
104        session.context.attributes.insert(
105            "working_directory".to_string(),
106            Value::String("/repo/fallback".to_string()),
107        );
108
109        assert_eq!(working_directory(&session), Some("/repo/preferred"));
110    }
111
112    #[test]
113    fn working_directory_uses_working_directory_fallback() {
114        let mut session = make_session();
115        session.context.attributes.insert(
116            "working_directory".to_string(),
117            Value::String("/repo/fallback".to_string()),
118        );
119
120        assert_eq!(working_directory(&session), Some("/repo/fallback"));
121    }
122
123    #[test]
124    fn source_path_returns_non_empty_value() {
125        let mut session = make_session();
126        session.context.attributes.insert(
127            "source_path".to_string(),
128            Value::String("/tmp/session.jsonl".to_string()),
129        );
130
131        assert_eq!(source_path(&session), Some("/tmp/session.jsonl"));
132    }
133
134    #[test]
135    fn session_role_uses_explicit_attribute_first() {
136        let mut session = make_session();
137        session.context.related_session_ids = vec!["parent-id".to_string()];
138        session.context.attributes.insert(
139            ATTR_SESSION_ROLE.to_string(),
140            Value::String("primary".to_string()),
141        );
142
143        assert_eq!(session_role(&session), SessionRole::Primary);
144        assert!(!is_auxiliary_session(&session));
145    }
146
147    #[test]
148    fn session_role_uses_related_session_ids() {
149        let mut session = make_session();
150        session.context.related_session_ids = vec!["parent-id".to_string()];
151
152        assert_eq!(session_role(&session), SessionRole::Auxiliary);
153        assert!(is_auxiliary_session(&session));
154    }
155
156    #[test]
157    fn session_role_uses_parent_session_id_attribute() {
158        let mut session = make_session();
159        session.context.attributes.insert(
160            ATTR_PARENT_SESSION_ID.to_string(),
161            Value::String("parent-id".to_string()),
162        );
163
164        assert_eq!(session_role(&session), SessionRole::Auxiliary);
165    }
166
167    #[test]
168    fn session_role_defaults_to_primary() {
169        let session = make_session();
170        assert_eq!(session_role(&session), SessionRole::Primary);
171    }
172}