Skip to main content

heddle_object_model/object/
session.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Session tracking for multi-provider agent workflows.
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use super::Principal;
8
9pub fn generate_session_id() -> String {
10    let random_bytes: [u8; 10] = rand::random();
11    format!(
12        "sess-{}",
13        base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &random_bytes).to_lowercase()
14    )
15}
16
17pub fn generate_segment_id(session_id: &str, segment_number: u32) -> String {
18    format!("{}-seg-{}", session_id, segment_number)
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Session {
23    pub id: String,
24    pub principal: Principal,
25    pub created_at: DateTime<Utc>,
26    pub ended_at: Option<DateTime<Utc>>,
27    pub segments: Vec<SessionSegment>,
28    pub current_segment_id: Option<String>,
29}
30
31impl Session {
32    pub fn new(
33        id: String,
34        principal: Principal,
35        provider: String,
36        model: String,
37        policy_id: Option<String>,
38    ) -> Self {
39        let segment_id = generate_segment_id(&id, 1);
40        let segment = SessionSegment {
41            id: segment_id.clone(),
42            provider,
43            model,
44            started_at: Utc::now(),
45            policy_id,
46            thought_level: None,
47        };
48        Self {
49            id,
50            principal,
51            created_at: Utc::now(),
52            ended_at: None,
53            segments: vec![segment],
54            current_segment_id: Some(segment_id),
55        }
56    }
57
58    pub fn is_active(&self) -> bool {
59        self.ended_at.is_none()
60    }
61
62    pub fn current_segment(&self) -> Option<&SessionSegment> {
63        self.current_segment_id
64            .as_ref()
65            .and_then(|id| self.segments.iter().find(|s| &s.id == id))
66    }
67
68    pub fn add_segment(
69        &mut self,
70        provider: String,
71        model: String,
72        policy_id: Option<String>,
73    ) -> &SessionSegment {
74        let segment_number = self.segments.len() as u32 + 1;
75        let segment_id = generate_segment_id(&self.id, segment_number);
76        let segment = SessionSegment {
77            id: segment_id.clone(),
78            provider,
79            model,
80            started_at: Utc::now(),
81            policy_id,
82            thought_level: None,
83        };
84        self.segments.push(segment);
85        self.current_segment_id = Some(segment_id);
86        self.segments.last().expect("segment was just pushed")
87    }
88
89    /// Attach unpublished thought_level onto the current segment (empty → set).
90    pub fn attach_thought_level(&mut self, thought_level: impl Into<String>) {
91        let Some(id) = self.current_segment_id.clone() else {
92            return;
93        };
94        if let Some(segment) = self.segments.iter_mut().find(|segment| segment.id == id)
95            && segment.thought_level.is_none()
96        {
97            segment.thought_level = Some(thought_level.into());
98        }
99    }
100
101    pub fn current_segment_mut(&mut self) -> Option<&mut SessionSegment> {
102        let id = self.current_segment_id.as_ref()?;
103        self.segments.iter_mut().find(|segment| &segment.id == id)
104    }
105
106    pub fn end(&mut self) {
107        self.ended_at = Some(Utc::now());
108        self.current_segment_id = None;
109    }
110}
111
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct SessionSegment {
114    pub id: String,
115    pub provider: String,
116    pub model: String,
117    pub started_at: DateTime<Utc>,
118    pub policy_id: Option<String>,
119    /// Published thought_level for this segment. Empty → set attaches in place.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub thought_level: Option<String>,
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_session_creation() {
130        let principal = Principal::new("Test User", "test@example.com");
131        let session = Session::new(
132            "sess-test123".to_string(),
133            principal.clone(),
134            "anthropic".to_string(),
135            "claude-opus-4".to_string(),
136            None,
137        );
138
139        assert_eq!(session.id, "sess-test123");
140        assert_eq!(session.principal, principal);
141        assert!(session.is_active());
142        assert!(session.ended_at.is_none());
143        assert_eq!(session.segments.len(), 1);
144        assert!(session.current_segment_id.is_some());
145    }
146
147    #[test]
148    fn test_segment_id_format() {
149        let segment_id = generate_segment_id("sess-test123", 1);
150        assert_eq!(segment_id, "sess-test123-seg-1");
151
152        let segment_id = generate_segment_id("sess-test123", 2);
153        assert_eq!(segment_id, "sess-test123-seg-2");
154    }
155
156    #[test]
157    fn test_current_segment() {
158        let principal = Principal::new("Test User", "test@example.com");
159        let session = Session::new(
160            "sess-test123".to_string(),
161            principal,
162            "anthropic".to_string(),
163            "claude-opus-4".to_string(),
164            None,
165        );
166
167        let segment = session.current_segment().unwrap();
168        assert_eq!(segment.provider, "anthropic");
169        assert_eq!(segment.model, "claude-opus-4");
170    }
171
172    #[test]
173    fn test_add_segment() {
174        let principal = Principal::new("Test User", "test@example.com");
175        let mut session = Session::new(
176            "sess-test123".to_string(),
177            principal,
178            "anthropic".to_string(),
179            "claude-opus-4".to_string(),
180            None,
181        );
182
183        let segment = session.add_segment(
184            "openai".to_string(),
185            "gpt-4".to_string(),
186            Some("policy-123".to_string()),
187        );
188
189        let segment_id = segment.id.clone();
190        let segment_provider = segment.provider.clone();
191        let segment_model = segment.model.clone();
192        let segment_policy_id = segment.policy_id.clone();
193
194        assert_eq!(session.segments.len(), 2);
195        assert_eq!(segment_id, "sess-test123-seg-2");
196        assert_eq!(segment_provider, "openai");
197        assert_eq!(segment_model, "gpt-4");
198        assert_eq!(segment_policy_id, Some("policy-123".to_string()));
199
200        let current = session.current_segment().unwrap();
201        assert_eq!(current.id, "sess-test123-seg-2");
202    }
203
204    #[test]
205    fn test_end_session() {
206        let principal = Principal::new("Test User", "test@example.com");
207        let mut session = Session::new(
208            "sess-test123".to_string(),
209            principal,
210            "anthropic".to_string(),
211            "claude-opus-4".to_string(),
212            None,
213        );
214
215        assert!(session.is_active());
216
217        session.end();
218
219        assert!(!session.is_active());
220        assert!(session.ended_at.is_some());
221        assert!(session.current_segment_id.is_none());
222    }
223
224    #[test]
225    fn test_session_serialization() {
226        let principal = Principal::new("Test User", "test@example.com");
227        let session = Session::new(
228            "sess-test123".to_string(),
229            principal,
230            "anthropic".to_string(),
231            "claude-opus-4".to_string(),
232            Some("policy-abc".to_string()),
233        );
234
235        let json = serde_json::to_string(&session).unwrap();
236        let deserialized: Session = serde_json::from_str(&json).unwrap();
237
238        assert_eq!(session, deserialized);
239    }
240}