1use serde::{Serialize, Deserialize};
2use serde_json::Value;
3use std::path::PathBuf;
4use chrono::{DateTime, Utc};
5
6
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Session {
10 pub id: String,
11 pub title: String,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 pub name: Option<String>,
14 pub model: String,
15 pub thinking_level: String,
16 pub system_prompt: Option<String>,
17 pub created_at: DateTime<Utc>,
18 pub updated_at: DateTime<Utc>,
19 pub total_input_tokens: u64,
20 pub total_output_tokens: u64,
21 pub session_cost: f64,
22 pub api_messages: Vec<Value>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub abort_context: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub parent_session: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub compacted_into: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SessionInfo {
37 pub id: String,
38 pub title: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub name: Option<String>,
41 pub model: String,
42 pub created_at: DateTime<Utc>,
43 pub updated_at: DateTime<Utc>,
44 pub session_cost: f64,
45 pub message_count: usize,
46}
47
48impl Session {
49 pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
50 let now = Utc::now();
51 let id = format!("{}-{}", now.format("%Y%m%d-%H%M%S"), &uuid::Uuid::new_v4().to_string()[..4]);
52 Session {
53 id,
54 title: String::new(),
55 name: None,
56 model: model.to_string(),
57 thinking_level: thinking_level.to_string(),
58 system_prompt: system_prompt.map(|s| s.to_string()),
59 created_at: now,
60 updated_at: now,
61 total_input_tokens: 0,
62 total_output_tokens: 0,
63 session_cost: 0.0,
64 api_messages: Vec::new(),
65 abort_context: None,
66 parent_session: None,
67 compacted_into: None,
68 }
69 }
70
71 pub fn new_from_compaction(parent: &Session, summary: String) -> Self {
73 let now = Utc::now();
74 let id = format!("{}-{}", now.format("%Y%m%d-%H%M%S"), &uuid::Uuid::new_v4().to_string()[..4]);
75 let name = parent.name.clone();
79 let mut summary_parts = String::new();
80 if let Some(ref sp) = parent.system_prompt {
81 summary_parts.push_str(&format!("<system-prompt>\n{}\n</system-prompt>\n\n", sp));
82 }
83 summary_parts.push_str(&format!(
84 "The conversation history before this point was compacted into the following summary:\n\n<context-summary>\n{}\n</context-summary>\n\nContinue from where we left off. The summary and system prompt above contain all the context you need.",
85 summary
86 ));
87 Session {
88 id,
89 title: format!("↳ {}", if parent.title.is_empty() { &parent.id } else { &parent.title }),
90 name,
91 model: parent.model.clone(),
92 thinking_level: parent.thinking_level.clone(),
93 system_prompt: parent.system_prompt.clone(),
94 created_at: now,
95 updated_at: now,
96 total_input_tokens: 0,
97 total_output_tokens: 0,
98 session_cost: 0.0,
99 api_messages: vec![
100 serde_json::json!({"role": "user", "content": summary_parts}),
101 serde_json::json!({"role": "assistant", "content": "I've loaded the conversation summary and system prompt. Ready to continue."}),
102 ],
103 abort_context: None,
104 parent_session: Some(parent.id.clone()),
105 compacted_into: None,
106 }
107 }
108
109 pub fn auto_title(&mut self) {
111 if !self.title.is_empty() {
112 return;
113 }
114 for msg in &self.api_messages {
115 if msg["role"].as_str() == Some("user") {
116 if let Some(content) = msg["content"].as_str() {
117 self.title = content.chars().take(80).collect();
118 return;
119 }
120 }
121 }
122 }
123
124 pub async fn save(&self) -> std::io::Result<()> {
125 let dir = crate::config::resolve_write_path("sessions");
126 tokio::fs::create_dir_all(&dir).await?;
127 let path = dir.join(format!("{}.json", self.id));
128 let tmp = path.with_extension("tmp");
129 let json = serde_json::to_string(self)
130 .map_err(std::io::Error::other)?;
131 tokio::fs::write(&tmp, &json).await?;
132 tokio::fs::rename(&tmp, &path).await
133 }
134
135 pub fn load(id: &str) -> std::io::Result<Self> {
136 let path = sessions_dir().join(format!("{}.json", id));
137 let content = std::fs::read_to_string(path)?;
138 serde_json::from_str(&content)
139 .map_err(std::io::Error::other)
140 }
141
142 pub fn info(&self) -> SessionInfo {
143 SessionInfo {
144 id: self.id.clone(),
145 title: self.title.clone(),
146 name: self.name.clone(),
147 model: self.model.clone(),
148 created_at: self.created_at,
149 updated_at: self.updated_at,
150 session_cost: self.session_cost,
151 message_count: self.api_messages.len(),
152 }
153 }
154
155 pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
159 validate_name(name).map_err(std::io::Error::other)?;
160 if self.name.as_deref() == Some(name) {
161 return Ok(());
162 }
163 let sessions = list_sessions()?;
164 for s in &sessions {
165 if s.name.as_deref() == Some(name) && s.id != self.id {
166 return Err(std::io::Error::other(format!(
167 "name '{}' already used by session {}",
168 name, s.id
169 )));
170 }
171 }
172 if crate::core::chain::load_chain(name).is_ok() {
173 return Err(std::io::Error::other(format!(
174 "name '{}' conflicts with an existing chain name",
175 name
176 )));
177 }
178 self.name = Some(name.to_string());
179 Ok(())
180 }
181
182 pub fn clear_name(&mut self) {
183 self.name = None;
184 }
185}
186
187pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
189 let dir = sessions_dir();
190 if !dir.exists() {
191 return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions directory"));
192 }
193
194 let exact = dir.join(format!("{}.json", partial_id));
196 if exact.exists() {
197 return Session::load(partial_id);
198 }
199
200 let mut matches: Vec<String> = Vec::new();
202 for entry in std::fs::read_dir(&dir)? {
203 let entry = entry?;
204 let name = entry.file_name().to_string_lossy().to_string();
205 if name.ends_with(".json") {
206 let id = name.trim_end_matches(".json");
207 if id.contains(partial_id) {
208 matches.push(id.to_string());
209 }
210 }
211 }
212
213 match matches.len() {
214 0 => Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("no session matching '{}'", partial_id))),
215 1 => Session::load(&matches[0]),
216 _ => Err(std::io::Error::other(format!("ambiguous: {} sessions match '{}'", matches.len(), partial_id))),
217 }
218}
219
220pub fn latest_session() -> std::io::Result<Session> {
222 let sessions = list_sessions()?;
223 sessions.into_iter()
224 .max_by_key(|s| s.updated_at)
225 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))
226 .and_then(|info| Session::load(&info.id))
227}
228
229pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
232 #[derive(Deserialize)]
234 struct SessionMetadata {
235 id: String,
236 #[serde(default)]
237 title: String,
238 #[serde(default)]
239 name: Option<String>,
240 model: String,
241 created_at: DateTime<Utc>,
242 updated_at: DateTime<Utc>,
243 #[serde(default)]
244 session_cost: f64,
245 #[serde(default)]
246 api_messages: Vec<serde::de::IgnoredAny>,
247 }
248
249 let dir = sessions_dir();
250 if !dir.exists() {
251 return Ok(Vec::new());
252 }
253
254 let mut sessions: Vec<SessionInfo> = Vec::new();
255 for entry in std::fs::read_dir(&dir)? {
256 let entry = entry?;
257 let path = entry.path();
258 if path.extension().is_some_and(|e| e == "json") {
259 if let Ok(content) = std::fs::read_to_string(&path) {
260 if let Ok(meta) = serde_json::from_str::<SessionMetadata>(&content) {
261 sessions.push(SessionInfo {
262 id: meta.id,
263 title: meta.title,
264 name: meta.name,
265 model: meta.model,
266 created_at: meta.created_at,
267 updated_at: meta.updated_at,
268 session_cost: meta.session_cost,
269 message_count: meta.api_messages.len(),
270 });
271 }
272 }
273 }
274 }
275
276 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
277 Ok(sessions)
278}
279
280fn sessions_dir() -> PathBuf {
281 crate::config::get_active_config_dir().join("sessions")
282}
283
284pub fn validate_name(name: &str) -> Result<(), String> {
286 if name.is_empty() {
287 return Err("name cannot be empty".into());
288 }
289 if name.len() > 40 {
290 return Err(format!("invalid name '{}': must be 40 chars or less", name));
291 }
292 if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
293 return Err(format!(
294 "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
295 name
296 ));
297 }
298 Ok(())
299}
300
301pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
303 let sessions = list_sessions()?;
304 for s in &sessions {
305 if s.name.as_deref() == Some(name) {
306 return Session::load(&s.id);
307 }
308 }
309 Err(std::io::Error::new(
310 std::io::ErrorKind::NotFound,
311 format!("no session named '{}'", name),
312 ))
313}
314
315pub fn resolve_session(query: &str) -> std::io::Result<Session> {
318 if let Ok(ptr) = crate::core::chain::load_chain(query) {
319 match Session::load(&ptr.head) {
320 Ok(s) => {
321 tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
322 return Ok(s);
323 }
324 Err(e) => {
325 return Err(std::io::Error::new(
326 e.kind(),
327 format!(
328 "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
329 query, ptr.head, e, query
330 ),
331 ));
332 }
333 }
334 }
335 if let Ok(s) = find_session_by_name(query) {
336 tracing::info!("resolved '{}' via session name → {}", query, s.id);
337 return Ok(s);
338 }
339 find_session(query)
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use serde_json::json;
346
347 #[test]
348 fn test_session_new() {
349 let session = Session::new("gpt-4", "brief", Some("test prompt"));
350
351 assert_eq!(session.model, "gpt-4");
353 assert_eq!(session.thinking_level, "brief");
354 assert_eq!(session.system_prompt, Some("test prompt".to_string()));
355
356 assert!(!session.id.is_empty());
358
359 assert_eq!(session.title, "");
361
362 assert_eq!(session.total_input_tokens, 0);
364 assert_eq!(session.total_output_tokens, 0);
365
366 assert_eq!(session.session_cost, 0.0);
368
369 assert!(session.api_messages.is_empty());
371
372 let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
374 assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
375 assert_eq!(session_no_prompt.thinking_level, "normal");
376 assert_eq!(session_no_prompt.system_prompt, None);
377 }
378
379 #[test]
380 fn test_session_auto_title() {
381 let mut session = Session::new("gpt-4", "brief", None);
382
383 session.api_messages.push(json!({
385 "role": "user",
386 "content": "hello world"
387 }));
388
389 session.auto_title();
391
392 assert_eq!(session.title, "hello world");
394
395 session.title = "existing title".to_string();
397 session.auto_title();
398 assert_eq!(session.title, "existing title");
399
400 let mut empty_session = Session::new("gpt-4", "brief", None);
402 empty_session.auto_title();
403 assert_eq!(empty_session.title, "");
404
405 let mut session_no_user = Session::new("gpt-4", "brief", None);
407 session_no_user.api_messages.push(json!({
408 "role": "assistant",
409 "content": "response"
410 }));
411 session_no_user.auto_title();
412 assert_eq!(session_no_user.title, "");
413
414 let mut session_long = Session::new("gpt-4", "brief", None);
416 let long_content = "a".repeat(100);
417 session_long.api_messages.push(json!({
418 "role": "user",
419 "content": long_content
420 }));
421 session_long.auto_title();
422 assert_eq!(session_long.title.len(), 80);
423 assert_eq!(session_long.title, "a".repeat(80));
424 }
425
426 #[test]
427 fn test_session_info() {
428 let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
429
430 session.api_messages.push(json!({
432 "role": "user",
433 "content": "test message"
434 }));
435 session.api_messages.push(json!({
436 "role": "assistant",
437 "content": "test response"
438 }));
439
440 session.title = "Test Title".to_string();
441 session.session_cost = 0.05;
442
443 let info = session.info();
444
445 assert_eq!(info.id, session.id);
446 assert_eq!(info.title, "Test Title");
447 assert_eq!(info.model, "gpt-4");
448 assert_eq!(info.created_at, session.created_at);
449 assert_eq!(info.updated_at, session.updated_at);
450 assert_eq!(info.session_cost, 0.05);
451 assert_eq!(info.message_count, 2);
452 }
453
454 #[test]
455 fn test_session_info_struct() {
456 let now = Utc::now();
457
458 let session_info = SessionInfo {
459 id: "test-id".to_string(),
460 title: "Test Title".to_string(),
461 name: None,
462 model: "gpt-4".to_string(),
463 created_at: now,
464 updated_at: now,
465 session_cost: 1.23,
466 message_count: 5,
467 };
468
469 assert_eq!(session_info.id, "test-id");
471 assert_eq!(session_info.title, "Test Title");
472 assert_eq!(session_info.model, "gpt-4");
473 assert_eq!(session_info.created_at, now);
474 assert_eq!(session_info.updated_at, now);
475 assert_eq!(session_info.session_cost, 1.23);
476 assert_eq!(session_info.message_count, 5);
477 }
478
479 #[test]
480 fn test_session_serialization_round_trip() {
481 let mut session = Session::new("gpt-4-turbo", "detailed", Some("You are a helpful assistant"));
482 session.title = "Test Session".to_string();
483 session.api_messages.push(json!({"role": "user", "content": "test"}));
484 session.total_input_tokens = 100;
485 session.total_output_tokens = 200;
486 session.session_cost = 0.15;
487
488 let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
490
491 let deserialized: Session = serde_json::from_str(&json_str).expect("Failed to deserialize session");
493
494 assert_eq!(deserialized.id, session.id);
496 assert_eq!(deserialized.title, session.title);
497 assert_eq!(deserialized.model, session.model);
498 assert_eq!(deserialized.thinking_level, session.thinking_level);
499 assert_eq!(deserialized.system_prompt, session.system_prompt);
500 assert_eq!(deserialized.created_at, session.created_at);
501 assert_eq!(deserialized.updated_at, session.updated_at);
502 assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
503 assert_eq!(deserialized.total_output_tokens, session.total_output_tokens);
504 assert_eq!(deserialized.session_cost, session.session_cost);
505 assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
506 assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
507 }
508
509 #[test]
510 fn test_session_serialization_preserves_all_fields() {
511 let mut session = Session::new("claude-3-opus", "comprehensive", Some("Custom system prompt"));
512 session.title = "Complex Session".to_string();
513
514 session.api_messages.push(json!({"role": "user", "content": "First message"}));
516 session.api_messages.push(json!({"role": "assistant", "content": "First response"}));
517 session.api_messages.push(json!({"role": "user", "content": "Second message"}));
518
519 session.total_input_tokens = 1500;
521 session.total_output_tokens = 2500;
522 session.session_cost = 0.75;
523
524 let json_str = serde_json::to_string(&session).unwrap();
526 let restored: Session = serde_json::from_str(&json_str).unwrap();
527
528 assert_eq!(restored.id, session.id);
530 assert_eq!(restored.title, "Complex Session");
531 assert_eq!(restored.model, "claude-3-opus");
532 assert_eq!(restored.thinking_level, "comprehensive");
533 assert_eq!(restored.system_prompt.as_ref().unwrap(), "Custom system prompt");
534 assert_eq!(restored.created_at, session.created_at);
535 assert_eq!(restored.updated_at, session.updated_at);
536 assert_eq!(restored.total_input_tokens, 1500);
537 assert_eq!(restored.total_output_tokens, 2500);
538 assert_eq!(restored.session_cost, 0.75);
539 assert_eq!(restored.api_messages.len(), 3);
540 assert_eq!(restored.api_messages[0]["role"], "user");
541 assert_eq!(restored.api_messages[0]["content"], "First message");
542 assert_eq!(restored.api_messages[1]["role"], "assistant");
543 assert_eq!(restored.api_messages[2]["content"], "Second message");
544 }
545
546 #[test]
547 fn test_session_info_from_session_with_messages() {
548 let mut session = Session::new("gpt-3.5-turbo", "normal", None);
549
550 session.api_messages.push(json!({"role": "user", "content": "message 1"}));
552 session.api_messages.push(json!({"role": "assistant", "content": "response 1"}));
553 session.api_messages.push(json!({"role": "user", "content": "message 2"}));
554
555 let info = session.info();
556
557 assert_eq!(info.message_count, 3);
559 assert_eq!(info.id, session.id);
560 assert_eq!(info.model, "gpt-3.5-turbo");
561 }
562
563 #[test]
564 fn test_session_auto_title_truncation() {
565 let mut session = Session::new("gpt-4", "brief", None);
566
567 let long_content = "a".repeat(200);
569 session.api_messages.push(json!({
570 "role": "user",
571 "content": long_content
572 }));
573
574 session.auto_title();
575
576 assert_eq!(session.title.len(), 80);
578 assert_eq!(session.title, "a".repeat(80));
579 }
580
581 #[test]
582 fn test_session_auto_title_skips_non_user_messages() {
583 let mut session = Session::new("gpt-4", "brief", None);
584
585 session.api_messages.push(json!({
587 "role": "assistant",
588 "content": "This should be ignored for auto title"
589 }));
590
591 session.auto_title();
592
593 assert_eq!(session.title, "");
595
596 session.api_messages.push(json!({
598 "role": "system",
599 "content": "System message should also be ignored"
600 }));
601
602 session.auto_title();
603 assert_eq!(session.title, "");
604 }
605
606 #[test]
607 fn test_session_new_generates_unique_ids() {
608 let session1 = Session::new("gpt-4", "brief", None);
609 let session2 = Session::new("gpt-4", "brief", None);
610
611 assert_ne!(session1.id, session2.id);
613 assert!(!session1.id.is_empty());
614 assert!(!session2.id.is_empty());
615 }
616
617 #[test]
618 fn test_session_new_timestamps() {
619 let before = Utc::now();
620 let session = Session::new("gpt-4", "brief", None);
621 let after = Utc::now();
622
623 let created_diff = (session.created_at - before).num_seconds().abs();
625 let updated_diff = (session.updated_at - before).num_seconds().abs();
626
627 assert!(created_diff <= 2, "created_at should be within 2 seconds of now");
628 assert!(updated_diff <= 2, "updated_at should be within 2 seconds of now");
629
630 assert_eq!(session.created_at, session.updated_at);
632
633 assert!(session.created_at <= after);
635 assert!(session.updated_at <= after);
636 }
637
638 #[test]
639 fn test_validate_name() {
640 assert!(validate_name("work").is_ok());
641 assert!(validate_name("my-project-2").is_ok());
642 assert!(validate_name("a").is_ok());
643 assert!(validate_name(&"a".repeat(40)).is_ok());
644
645 assert!(validate_name("").is_err());
646 assert!(validate_name(&"a".repeat(41)).is_err());
647 assert!(validate_name("UPPER").is_err());
648 assert!(validate_name("has space").is_err());
649 assert!(validate_name("under_score").is_err());
650 assert!(validate_name("dots.bad").is_err());
651
652 let err = validate_name("Bad").unwrap_err();
653 assert!(err.contains("Bad"));
654 assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
655 }
656
657 #[test]
658 fn test_clear_name() {
659 let mut s = Session::new("m", "brief", None);
660 s.name = Some("foo".into());
661 s.clear_name();
662 assert_eq!(s.name, None);
663 }
664}