Skip to main content

opensession_core/
session.rs

1use crate::trace::Session;
2use serde::{Deserialize, Serialize};
3
4pub const ATTR_CWD: &str = "cwd";
5pub const ATTR_WORKING_DIRECTORY: &str = "working_directory";
6pub const ATTR_SOURCE_PATH: &str = "source_path";
7pub const ATTR_SESSION_ROLE: &str = "session_role";
8pub const ATTR_PARENT_SESSION_ID: &str = "parent_session_id";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SessionRole {
12    Primary,
13    Auxiliary,
14}
15
16fn attr_non_empty_str<'a>(session: &'a Session, key: &str) -> Option<&'a str> {
17    session
18        .context
19        .attributes
20        .get(key)
21        .and_then(|value| value.as_str())
22        .map(str::trim)
23        .filter(|value| !value.is_empty())
24}
25
26pub fn working_directory(session: &Session) -> Option<&str> {
27    attr_non_empty_str(session, ATTR_CWD)
28        .or_else(|| attr_non_empty_str(session, ATTR_WORKING_DIRECTORY))
29}
30
31pub fn source_path(session: &Session) -> Option<&str> {
32    attr_non_empty_str(session, ATTR_SOURCE_PATH)
33}
34
35pub fn session_role(session: &Session) -> SessionRole {
36    if let Some(raw_role) = attr_non_empty_str(session, ATTR_SESSION_ROLE) {
37        if raw_role.eq_ignore_ascii_case("auxiliary") {
38            return SessionRole::Auxiliary;
39        }
40        if raw_role.eq_ignore_ascii_case("primary") {
41            return SessionRole::Primary;
42        }
43    }
44
45    if !session.context.related_session_ids.is_empty() {
46        return SessionRole::Auxiliary;
47    }
48
49    if attr_non_empty_str(session, ATTR_PARENT_SESSION_ID).is_some() {
50        return SessionRole::Auxiliary;
51    }
52
53    SessionRole::Primary
54}
55
56pub fn is_auxiliary_session(session: &Session) -> bool {
57    session_role(session) == SessionRole::Auxiliary
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
61pub struct GitMeta {
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub remote: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub repo_name: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub branch: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub head: Option<String>,
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub commits: Vec<String>,
72}
73
74pub fn build_git_storage_meta_json_with_git(session: &Session, git: Option<&GitMeta>) -> Vec<u8> {
75    let role = match session_role(session) {
76        SessionRole::Primary => "primary",
77        SessionRole::Auxiliary => "auxiliary",
78    };
79
80    let mut payload = serde_json::json!({
81        "schema_version": 2,
82        "session_id": session.session_id,
83        "title": session.context.title,
84        "tool": session.agent.tool,
85        "model": session.agent.model,
86        "session_role": role,
87        "stats": session.stats,
88    });
89
90    if let Some(git_meta) = git {
91        let has_git = git_meta.remote.is_some()
92            || git_meta.repo_name.is_some()
93            || git_meta.branch.is_some()
94            || git_meta.head.is_some()
95            || !git_meta.commits.is_empty();
96        if has_git {
97            payload["git"] = serde_json::to_value(git_meta).unwrap_or_default();
98        }
99    }
100
101    serde_json::to_vec_pretty(&payload).unwrap_or_default()
102}
103
104pub fn build_git_storage_meta_json(session: &Session) -> Vec<u8> {
105    build_git_storage_meta_json_with_git(session, None)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::{
111        build_git_storage_meta_json, build_git_storage_meta_json_with_git, is_auxiliary_session,
112        session_role, source_path, working_directory, GitMeta, SessionRole, ATTR_PARENT_SESSION_ID,
113        ATTR_SESSION_ROLE,
114    };
115    use crate::trace::{Agent, Session};
116    use serde_json::Value;
117
118    fn make_session() -> Session {
119        Session::new(
120            "s1".to_string(),
121            Agent {
122                provider: "openai".to_string(),
123                model: "gpt-5".to_string(),
124                tool: "codex".to_string(),
125                tool_version: None,
126            },
127        )
128    }
129
130    #[test]
131    fn working_directory_prefers_cwd() {
132        let mut session = make_session();
133        session.context.attributes.insert(
134            "cwd".to_string(),
135            Value::String("/repo/preferred".to_string()),
136        );
137        session.context.attributes.insert(
138            "working_directory".to_string(),
139            Value::String("/repo/fallback".to_string()),
140        );
141
142        assert_eq!(working_directory(&session), Some("/repo/preferred"));
143    }
144
145    #[test]
146    fn working_directory_uses_working_directory_fallback() {
147        let mut session = make_session();
148        session.context.attributes.insert(
149            "working_directory".to_string(),
150            Value::String("/repo/fallback".to_string()),
151        );
152
153        assert_eq!(working_directory(&session), Some("/repo/fallback"));
154    }
155
156    #[test]
157    fn source_path_returns_non_empty_value() {
158        let mut session = make_session();
159        session.context.attributes.insert(
160            "source_path".to_string(),
161            Value::String("/tmp/session.jsonl".to_string()),
162        );
163
164        assert_eq!(source_path(&session), Some("/tmp/session.jsonl"));
165    }
166
167    #[test]
168    fn session_role_uses_explicit_attribute_first() {
169        let mut session = make_session();
170        session.context.related_session_ids = vec!["parent-id".to_string()];
171        session.context.attributes.insert(
172            ATTR_SESSION_ROLE.to_string(),
173            Value::String("primary".to_string()),
174        );
175
176        assert_eq!(session_role(&session), SessionRole::Primary);
177        assert!(!is_auxiliary_session(&session));
178    }
179
180    #[test]
181    fn session_role_uses_related_session_ids() {
182        let mut session = make_session();
183        session.context.related_session_ids = vec!["parent-id".to_string()];
184
185        assert_eq!(session_role(&session), SessionRole::Auxiliary);
186        assert!(is_auxiliary_session(&session));
187    }
188
189    #[test]
190    fn session_role_uses_parent_session_id_attribute() {
191        let mut session = make_session();
192        session.context.attributes.insert(
193            ATTR_PARENT_SESSION_ID.to_string(),
194            Value::String("parent-id".to_string()),
195        );
196
197        assert_eq!(session_role(&session), SessionRole::Auxiliary);
198    }
199
200    #[test]
201    fn session_role_defaults_to_primary() {
202        let session = make_session();
203        assert_eq!(session_role(&session), SessionRole::Primary);
204    }
205
206    #[test]
207    fn git_storage_meta_defaults_to_schema_v2_without_git() {
208        let session = make_session();
209        let bytes = build_git_storage_meta_json(&session);
210        let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
211        assert_eq!(parsed["schema_version"], 2);
212        assert!(parsed.get("git").is_none());
213    }
214
215    #[test]
216    fn git_storage_meta_includes_git_block_when_present() {
217        let session = make_session();
218        let git = GitMeta {
219            remote: Some("git@github.com:org/repo.git".to_string()),
220            repo_name: Some("org/repo".to_string()),
221            branch: Some("feature/x".to_string()),
222            head: Some("abcd1234".to_string()),
223            commits: vec!["abcd1234".to_string(), "beef5678".to_string()],
224        };
225        let bytes = build_git_storage_meta_json_with_git(&session, Some(&git));
226        let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
227        assert_eq!(parsed["schema_version"], 2);
228        assert_eq!(parsed["git"]["repo_name"], "org/repo");
229        assert_eq!(parsed["git"]["commits"][0], "abcd1234");
230    }
231}