1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5pub type SessionId = Uuid;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum DataSensitivity {
12 Public,
13 Internal,
14 Confidential,
15 Restricted,
16}
17
18fn default_rate_limit_window_secs() -> u64 {
19 60
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum SessionStatus {
26 Active,
27 Closed,
28 Expired,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TaskSession {
34 pub session_id: SessionId,
36
37 pub agent_id: Uuid,
39
40 pub delegation_chain_snapshot: Vec<String>,
42
43 pub declared_intent: String,
45
46 pub authorized_tools: Vec<String>,
48
49 pub time_limit: chrono::Duration,
51
52 pub call_budget: u64,
54
55 pub calls_made: u64,
57
58 #[serde(default)]
60 pub rate_limit_per_minute: Option<u64>,
61
62 #[serde(default = "Utc::now")]
64 pub rate_window_start: DateTime<Utc>,
65
66 #[serde(default)]
68 pub rate_window_calls: u64,
69
70 #[serde(default = "default_rate_limit_window_secs")]
72 pub rate_limit_window_secs: u64,
73
74 pub data_sensitivity_ceiling: DataSensitivity,
76
77 pub created_at: DateTime<Utc>,
79
80 pub status: SessionStatus,
82}
83
84impl TaskSession {
85 pub fn is_expired(&self) -> bool {
87 let elapsed = Utc::now() - self.created_at;
88 elapsed > self.time_limit || self.status == SessionStatus::Expired
89 }
90
91 pub fn is_budget_exceeded(&self) -> bool {
93 self.calls_made >= self.call_budget
94 }
95
96 pub fn is_tool_authorized(&self, tool_name: &str) -> bool {
98 self.authorized_tools.is_empty() || self.authorized_tools.iter().any(|t| t == tool_name)
100 }
101
102 pub fn is_active(&self) -> bool {
104 self.status == SessionStatus::Active && !self.is_expired() && !self.is_budget_exceeded()
105 }
106
107 pub fn check_rate_limit(&mut self) -> bool {
110 let limit = match self.rate_limit_per_minute {
111 Some(l) => l,
112 None => return false,
113 };
114 let now = Utc::now();
115 let elapsed = now - self.rate_window_start;
116 if elapsed >= chrono::Duration::seconds(self.rate_limit_window_secs as i64) {
117 self.rate_window_start = now;
119 self.rate_window_calls = 1;
120 false
121 } else if self.rate_window_calls >= limit {
122 true } else {
124 self.rate_window_calls += 1;
125 false
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn test_session() -> TaskSession {
135 TaskSession {
136 session_id: Uuid::new_v4(),
137 agent_id: Uuid::new_v4(),
138 delegation_chain_snapshot: vec![],
139 declared_intent: "read files".into(),
140 authorized_tools: vec!["read_file".into(), "list_dir".into()],
141 time_limit: chrono::Duration::hours(1),
142 call_budget: 100,
143 calls_made: 0,
144 rate_limit_per_minute: None,
145 rate_window_start: Utc::now(),
146 rate_window_calls: 0,
147 rate_limit_window_secs: 60,
148 data_sensitivity_ceiling: DataSensitivity::Internal,
149 created_at: Utc::now(),
150 status: SessionStatus::Active,
151 }
152 }
153
154 #[test]
155 fn active_session_is_usable() {
156 let session = test_session();
157 assert!(session.is_active());
158 assert!(!session.is_expired());
159 assert!(!session.is_budget_exceeded());
160 }
161
162 #[test]
163 fn tool_authorization_check() {
164 let session = test_session();
165 assert!(session.is_tool_authorized("read_file"));
166 assert!(session.is_tool_authorized("list_dir"));
167 assert!(!session.is_tool_authorized("delete_file"));
168 }
169
170 #[test]
171 fn budget_exhaustion() {
172 let mut session = test_session();
173 session.calls_made = 100;
174 assert!(session.is_budget_exceeded());
175 assert!(!session.is_active());
176 }
177
178 #[test]
179 fn expired_session() {
180 let mut session = test_session();
181 session.created_at = Utc::now() - chrono::Duration::hours(2);
182 assert!(session.is_expired());
183 assert!(!session.is_active());
184 }
185
186 #[test]
187 fn rate_limit_none_always_allows() {
188 let mut session = test_session();
189 assert_eq!(session.rate_limit_per_minute, None);
190 for _ in 0..1000 {
191 assert!(
192 !session.check_rate_limit(),
193 "None rate limit must never deny"
194 );
195 }
196 assert_eq!(session.rate_window_calls, 0);
199 }
200
201 #[test]
202 fn rate_limit_under_threshold_allows() {
203 let mut session = test_session();
204 session.rate_limit_per_minute = Some(5);
205 for i in 0..4 {
206 assert!(
207 !session.check_rate_limit(),
208 "Call {} should be allowed under threshold of 5",
209 i + 1
210 );
211 }
212 assert_eq!(session.rate_window_calls, 4);
213 }
214
215 #[test]
216 fn rate_limit_at_threshold_denies() {
217 let mut session = test_session();
218 session.rate_limit_per_minute = Some(3);
219 session.rate_window_calls = 3;
221 assert!(
222 session.check_rate_limit(),
223 "Must deny when calls already at limit"
224 );
225 }
226
227 #[test]
228 fn rate_limit_window_reset() {
229 let mut session = test_session();
230 session.rate_limit_per_minute = Some(5);
231 session.rate_window_start = Utc::now() - chrono::Duration::seconds(61);
233 session.rate_window_calls = 5; let denied = session.check_rate_limit();
236 assert!(!denied, "New window should allow the call");
237 assert_eq!(
238 session.rate_window_calls, 1,
239 "Window must reset to 1 after a new window starts"
240 );
241 }
242
243 #[test]
244 fn empty_authorized_tools_allows_all() {
245 let mut session = test_session();
246 session.authorized_tools = vec![];
247 assert!(
248 session.is_tool_authorized("anything_goes"),
249 "Empty authorized_tools must allow any tool"
250 );
251 assert!(
252 session.is_tool_authorized("delete_file"),
253 "Empty authorized_tools must allow any tool"
254 );
255 assert!(
256 session.is_tool_authorized(""),
257 "Empty authorized_tools must allow even empty-string tool name"
258 );
259 }
260
261 #[test]
262 fn closed_session_not_active() {
263 let mut session = test_session();
264 session.status = SessionStatus::Closed;
265 assert!(
266 !session.is_active(),
267 "Closed session must not be considered active"
268 );
269 assert!(!session.is_expired());
271 assert!(!session.is_budget_exceeded());
272 }
273
274 #[test]
275 fn budget_boundary_at_limit_minus_one() {
276 let mut session = test_session();
277 session.calls_made = session.call_budget - 1;
278 assert!(
279 !session.is_budget_exceeded(),
280 "One call below budget must not be exceeded"
281 );
282 assert!(
283 session.is_active(),
284 "Session at budget - 1 should still be active"
285 );
286 }
287
288 #[test]
290 fn zero_budget_is_exceeded() {
291 let mut session = test_session();
292 session.call_budget = 0;
293 session.calls_made = 0;
294 assert!(
295 session.is_budget_exceeded(),
296 "0 >= 0 means budget is exceeded"
297 );
298 assert!(
299 !session.is_active(),
300 "zero-budget session should not be active"
301 );
302 }
303
304 #[test]
306 fn check_rate_limit_at_exact_window_boundary() {
307 let mut session = test_session();
308 session.rate_limit_per_minute = Some(5);
309 session.rate_window_start =
311 Utc::now() - chrono::Duration::seconds(session.rate_limit_window_secs as i64);
312 session.rate_window_calls = 5; let denied = session.check_rate_limit();
315 assert!(!denied, "exact window boundary should reset and allow");
316 assert_eq!(
317 session.rate_window_calls, 1,
318 "window must reset to 1 after boundary reset"
319 );
320 }
321}