Skip to main content

opensession_core/
session.rs

1use crate::trace::{Event, EventType, Session, Stats};
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/// Build an interaction-focused compressed session.
61///
62/// Only interaction-like events are retained and stats are recomputed.
63pub fn interaction_compressed_session(session: &Session) -> Session {
64    let mut compressed = session.clone();
65    compressed.events.retain(is_interaction_flow_event);
66    compressed.recompute_stats();
67    compressed
68}
69
70/// Build compressed stats by keeping only interaction-focused events.
71///
72/// This is intended for lightweight local index/cache storage where
73/// chronological full fidelity is not required.
74pub fn interaction_compressed_stats(session: &Session) -> Stats {
75    interaction_compressed_session(session).stats
76}
77
78fn is_interaction_flow_event(event: &Event) -> bool {
79    if is_interrupt_like_event(event) {
80        return true;
81    }
82
83    if matches!(
84        event.event_type,
85        EventType::UserMessage | EventType::AgentMessage
86    ) {
87        return true;
88    }
89
90    if matches!(event.event_type, EventType::SystemMessage)
91        && event
92            .attr_str("source")
93            .is_some_and(|source| source.eq_ignore_ascii_case("interactive_question"))
94    {
95        return true;
96    }
97
98    match &event.event_type {
99        EventType::ToolCall { name } | EventType::ToolResult { name, .. } => {
100            event
101                .semantic_tool_kind()
102                .is_some_and(|kind| kind.eq_ignore_ascii_case("interactive"))
103                || matches!(
104                    name.trim().to_ascii_lowercase().as_str(),
105                    "request_user_input" | "ask_followup_question"
106                )
107        }
108        _ => false,
109    }
110}
111
112fn is_interrupt_like_event(event: &Event) -> bool {
113    if let EventType::Custom { kind } = &event.event_type {
114        let lowered = kind.trim().to_ascii_lowercase();
115        return lowered == "turn_aborted"
116            || lowered.contains("interrupt")
117            || lowered.contains("aborted");
118    }
119    false
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123pub struct GitMeta {
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub remote: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub repo_name: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub branch: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub head: Option<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub commits: Vec<String>,
134}
135
136pub fn build_git_storage_meta_json_with_git(session: &Session, git: Option<&GitMeta>) -> Vec<u8> {
137    let role = match session_role(session) {
138        SessionRole::Primary => "primary",
139        SessionRole::Auxiliary => "auxiliary",
140    };
141
142    let mut payload = serde_json::json!({
143        "schema_version": 2,
144        "session_id": session.session_id,
145        "title": session.context.title,
146        "tool": session.agent.tool,
147        "model": session.agent.model,
148        "session_role": role,
149        "stats": session.stats,
150    });
151
152    if let Some(git_meta) = git {
153        let has_git = git_meta.remote.is_some()
154            || git_meta.repo_name.is_some()
155            || git_meta.branch.is_some()
156            || git_meta.head.is_some()
157            || !git_meta.commits.is_empty();
158        if has_git {
159            payload["git"] = serde_json::to_value(git_meta).unwrap_or_default();
160        }
161    }
162
163    serde_json::to_vec_pretty(&payload).unwrap_or_default()
164}
165
166pub fn build_git_storage_meta_json(session: &Session) -> Vec<u8> {
167    build_git_storage_meta_json_with_git(session, None)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::{
173        build_git_storage_meta_json, build_git_storage_meta_json_with_git,
174        interaction_compressed_session, interaction_compressed_stats, is_auxiliary_session,
175        session_role, source_path, working_directory, GitMeta, SessionRole, ATTR_PARENT_SESSION_ID,
176        ATTR_SESSION_ROLE,
177    };
178    use crate::trace::{Agent, Content, Event, EventType, Session};
179    use serde_json::Value;
180    use std::collections::HashMap;
181
182    fn make_session() -> Session {
183        Session::new(
184            "s1".to_string(),
185            Agent {
186                provider: "openai".to_string(),
187                model: "gpt-5".to_string(),
188                tool: "codex".to_string(),
189                tool_version: None,
190            },
191        )
192    }
193
194    #[test]
195    fn working_directory_prefers_cwd() {
196        let mut session = make_session();
197        session.context.attributes.insert(
198            "cwd".to_string(),
199            Value::String("/repo/preferred".to_string()),
200        );
201        session.context.attributes.insert(
202            "working_directory".to_string(),
203            Value::String("/repo/fallback".to_string()),
204        );
205
206        assert_eq!(working_directory(&session), Some("/repo/preferred"));
207    }
208
209    #[test]
210    fn working_directory_uses_working_directory_fallback() {
211        let mut session = make_session();
212        session.context.attributes.insert(
213            "working_directory".to_string(),
214            Value::String("/repo/fallback".to_string()),
215        );
216
217        assert_eq!(working_directory(&session), Some("/repo/fallback"));
218    }
219
220    #[test]
221    fn source_path_returns_non_empty_value() {
222        let mut session = make_session();
223        session.context.attributes.insert(
224            "source_path".to_string(),
225            Value::String("/tmp/session.jsonl".to_string()),
226        );
227
228        assert_eq!(source_path(&session), Some("/tmp/session.jsonl"));
229    }
230
231    #[test]
232    fn session_role_uses_explicit_attribute_first() {
233        let mut session = make_session();
234        session.context.related_session_ids = vec!["parent-id".to_string()];
235        session.context.attributes.insert(
236            ATTR_SESSION_ROLE.to_string(),
237            Value::String("primary".to_string()),
238        );
239
240        assert_eq!(session_role(&session), SessionRole::Primary);
241        assert!(!is_auxiliary_session(&session));
242    }
243
244    #[test]
245    fn session_role_uses_related_session_ids() {
246        let mut session = make_session();
247        session.context.related_session_ids = vec!["parent-id".to_string()];
248
249        assert_eq!(session_role(&session), SessionRole::Auxiliary);
250        assert!(is_auxiliary_session(&session));
251    }
252
253    #[test]
254    fn session_role_uses_parent_session_id_attribute() {
255        let mut session = make_session();
256        session.context.attributes.insert(
257            ATTR_PARENT_SESSION_ID.to_string(),
258            Value::String("parent-id".to_string()),
259        );
260
261        assert_eq!(session_role(&session), SessionRole::Auxiliary);
262    }
263
264    #[test]
265    fn session_role_defaults_to_primary() {
266        let session = make_session();
267        assert_eq!(session_role(&session), SessionRole::Primary);
268    }
269
270    #[test]
271    fn git_storage_meta_defaults_to_schema_v2_without_git() {
272        let session = make_session();
273        let bytes = build_git_storage_meta_json(&session);
274        let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
275        assert_eq!(parsed["schema_version"], 2);
276        assert!(parsed.get("git").is_none());
277    }
278
279    #[test]
280    fn git_storage_meta_includes_git_block_when_present() {
281        let session = make_session();
282        let git = GitMeta {
283            remote: Some("git@github.com:org/repo.git".to_string()),
284            repo_name: Some("org/repo".to_string()),
285            branch: Some("feature/x".to_string()),
286            head: Some("abcd1234".to_string()),
287            commits: vec!["abcd1234".to_string(), "beef5678".to_string()],
288        };
289        let bytes = build_git_storage_meta_json_with_git(&session, Some(&git));
290        let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
291        assert_eq!(parsed["schema_version"], 2);
292        assert_eq!(parsed["git"]["repo_name"], "org/repo");
293        assert_eq!(parsed["git"]["commits"][0], "abcd1234");
294    }
295
296    #[test]
297    fn interaction_compressed_stats_keeps_only_interaction_events() {
298        let mut session = make_session();
299        let mut interactive_system = Event {
300            event_id: "e-system".to_string(),
301            timestamp: chrono::Utc::now(),
302            event_type: EventType::SystemMessage,
303            task_id: None,
304            content: Content::text("question"),
305            duration_ms: None,
306            attributes: HashMap::new(),
307        };
308        interactive_system.attributes.insert(
309            "source".to_string(),
310            Value::String("interactive_question".to_string()),
311        );
312
313        session.events = vec![
314            Event {
315                event_id: "e-user".to_string(),
316                timestamp: chrono::Utc::now(),
317                event_type: EventType::UserMessage,
318                task_id: None,
319                content: Content::text("hello"),
320                duration_ms: None,
321                attributes: HashMap::new(),
322            },
323            Event {
324                event_id: "e-tool-non-interactive".to_string(),
325                timestamp: chrono::Utc::now(),
326                event_type: EventType::ToolCall {
327                    name: "write_file".to_string(),
328                },
329                task_id: None,
330                content: Content::text(""),
331                duration_ms: None,
332                attributes: HashMap::new(),
333            },
334            Event {
335                event_id: "e-tool-interactive".to_string(),
336                timestamp: chrono::Utc::now(),
337                event_type: EventType::ToolCall {
338                    name: "request_user_input".to_string(),
339                },
340                task_id: None,
341                content: Content::text(""),
342                duration_ms: None,
343                attributes: HashMap::new(),
344            },
345            Event {
346                event_id: "e-interrupt".to_string(),
347                timestamp: chrono::Utc::now(),
348                event_type: EventType::Custom {
349                    kind: "turn_aborted".to_string(),
350                },
351                task_id: None,
352                content: Content::text(""),
353                duration_ms: None,
354                attributes: HashMap::new(),
355            },
356            interactive_system,
357        ];
358        session.recompute_stats();
359        assert_eq!(session.stats.event_count, 5);
360
361        let compressed = interaction_compressed_stats(&session);
362        assert_eq!(compressed.event_count, 4);
363        assert_eq!(compressed.user_message_count, 1);
364        assert_eq!(compressed.tool_call_count, 1);
365    }
366
367    #[test]
368    fn interaction_compressed_session_retains_only_interaction_events() {
369        let mut session = make_session();
370        session.events = vec![
371            Event {
372                event_id: "e-user".to_string(),
373                timestamp: chrono::Utc::now(),
374                event_type: EventType::UserMessage,
375                task_id: None,
376                content: Content::text("hello"),
377                duration_ms: None,
378                attributes: HashMap::new(),
379            },
380            Event {
381                event_id: "e-tool-non-interactive".to_string(),
382                timestamp: chrono::Utc::now(),
383                event_type: EventType::ToolCall {
384                    name: "write_file".to_string(),
385                },
386                task_id: None,
387                content: Content::text(""),
388                duration_ms: None,
389                attributes: HashMap::new(),
390            },
391        ];
392        session.recompute_stats();
393
394        let compressed = interaction_compressed_session(&session);
395        assert_eq!(compressed.events.len(), 1);
396        assert!(matches!(
397            compressed.events[0].event_type,
398            EventType::UserMessage
399        ));
400        assert_eq!(compressed.stats.event_count, 1);
401    }
402}