opensession_core/
session.rs1use 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}