Skip to main content

agent_core/core/
session.rs

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    /// Saved abort context — injected into the next user message on /continue
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub abort_context: Option<String>,
26    /// ID of the session this was compacted from (backward link)
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub parent_session: Option<String>,
29    /// ID of the session created by compacting this one (forward link)
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub compacted_into: Option<String>,
32}
33
34/// Lightweight info for listing sessions without loading full message history
35#[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    /// Create a new session from a compaction summary, linked to the parent.
72    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        // Transfer session name from parent — the compacted session is the
76        // continuation, so the name should follow. Parent's name will be
77        // cleared when the caller saves it with compacted_into set.
78        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    /// Set title from the first user message if not already set
110    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    /// Assign a name to this session. Validates name, enforces uniqueness
156    /// across sessions, and rejects collisions with existing chain names.
157    /// Idempotent: re-applying the current name is a no-op.
158    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
187/// Find a session by full or partial ID match
188pub 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    // Try exact match first
195    let exact = dir.join(format!("{}.json", partial_id));
196    if exact.exists() {
197        return Session::load(partial_id);
198    }
199
200    // Partial match — find all that contain the partial ID
201    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
220/// Load the most recently updated session
221pub fn latest_session() -> std::io::Result<Session> {
222    // Find the most-recently-modified session file by FILE mtime — without
223    // reading or JSON-parsing any of them. The previous impl called
224    // list_sessions(), which read + serde-tokenized EVERY session file to sort
225    // by the in-file `updated_at`; with hundreds of multi-MB sessions (221 files
226    // / 76MB here) that made `--continue` boot take ~11s. mtime is a free, exact
227    // proxy for "the session I was last in", and we then load ONLY that one.
228    let dir = sessions_dir();
229    if !dir.exists() {
230        return Err(std::io::Error::new(
231            std::io::ErrorKind::NotFound,
232            "no sessions found",
233        ));
234    }
235    let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
236    for entry in std::fs::read_dir(&dir)? {
237        let entry = entry?;
238        let path = entry.path();
239        if path.extension().is_some_and(|e| e == "json") {
240            if let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) {
241                if newest.as_ref().map_or(true, |(t, _)| mtime > *t) {
242                    newest = Some((mtime, path));
243                }
244            }
245        }
246    }
247    let path = newest.map(|(_, p)| p).ok_or_else(|| {
248        std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found")
249    })?;
250    let id = path
251        .file_stem()
252        .and_then(|s| s.to_str())
253        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad session filename"))?;
254    Session::load(id)
255}
256
257/// List all sessions, sorted by most recently updated.
258///
259/// Reads only the metadata HEADER of each session file (see
260/// [`read_session_header`]) — never the message history. Used by the session
261/// resolvers (by name/id). For the capped `/sessions` display prefer
262/// [`list_recent_sessions`], which reads only the N most-recent headers.
263pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
264    let dir = sessions_dir();
265    if !dir.exists() {
266        return Ok(Vec::new());
267    }
268    let mut sessions: Vec<SessionInfo> = Vec::new();
269    for entry in std::fs::read_dir(&dir)? {
270        let entry = entry?;
271        let path = entry.path();
272        if path.extension().is_some_and(|e| e == "json") {
273            if let Some(info) = parse_session_header(&path) {
274                sessions.push(info);
275            }
276        }
277    }
278    sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
279    Ok(sessions)
280}
281
282/// The `limit` most-recently-modified sessions (by file mtime), most-recent
283/// first. Sorts the directory entries by mtime WITHOUT parsing, then reads only
284/// the top `limit` headers — so `/sessions` is O(limit) reads instead of
285/// O(#sessions). mtime is an exact proxy for `updated_at` (the file is rewritten
286/// on every save).
287pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
288    let dir = sessions_dir();
289    if !dir.exists() {
290        return Ok(Vec::new());
291    }
292    let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new();
293    for entry in std::fs::read_dir(&dir)? {
294        let entry = entry?;
295        let path = entry.path();
296        if path.extension().is_some_and(|e| e == "json") {
297            if let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) {
298                files.push((mtime, path));
299            }
300        }
301    }
302    files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
303    files.truncate(limit);
304    let mut sessions: Vec<SessionInfo> = Vec::new();
305    for (_, path) in files {
306        if let Some(info) = parse_session_header(&path) {
307            sessions.push(info);
308        }
309    }
310    Ok(sessions)
311}
312
313/// Read + parse just the metadata header of one session file into a
314/// [`SessionInfo`] (no message history). `message_count` is left 0 — it isn't
315/// parsed in the header read; use [`Session::info`] when an exact count matters.
316fn parse_session_header(path: &std::path::Path) -> Option<SessionInfo> {
317    #[derive(Deserialize)]
318    struct SessionMetadata {
319        id: String,
320        #[serde(default)]
321        title: String,
322        #[serde(default)]
323        name: Option<String>,
324        model: String,
325        created_at: DateTime<Utc>,
326        updated_at: DateTime<Utc>,
327        #[serde(default)]
328        session_cost: f64,
329    }
330    let header = read_session_header(path)?;
331    let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
332    Some(SessionInfo {
333        id: meta.id,
334        title: meta.title,
335        name: meta.name,
336        model: meta.model,
337        created_at: meta.created_at,
338        updated_at: meta.updated_at,
339        session_cost: meta.session_cost,
340        message_count: 0,
341    })
342}
343
344/// Read just the metadata header of a session file — everything BEFORE the
345/// (potentially multi-MB) `"api_messages"` array — and return it as a complete,
346/// parseable JSON object string. Reads in bounded chunks and STOPS as soon as
347/// the `api_messages` key is found, so it never reads or tokenizes the message
348/// history. Falls back to the whole file if the key isn't found (small/new
349/// sessions). This is what keeps `list_sessions()` O(#sessions) instead of
350/// O(total bytes on disk).
351fn read_session_header(path: &std::path::Path) -> Option<String> {
352    use std::io::Read;
353    const KEY: &[u8] = b"\"api_messages\"";
354    const MAX_HEADER: usize = 256 * 1024; // safety cap if the key is never found
355
356    let mut file = std::fs::File::open(path).ok()?;
357    let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
358    let mut chunk = [0u8; 16 * 1024];
359    let mut cut: Option<usize> = None;
360    while buf.len() <= MAX_HEADER {
361        let n = file.read(&mut chunk).ok()?;
362        if n == 0 {
363            break;
364        }
365        buf.extend_from_slice(&chunk[..n]);
366        if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
367            cut = Some(pos);
368            break;
369        }
370    }
371
372    let end = cut.unwrap_or(buf.len());
373    let trimmed = String::from_utf8_lossy(&buf[..end]);
374    let mut s = trimmed.trim_end().to_string();
375    if s.ends_with(',') {
376        s.pop();
377    }
378    if !s.ends_with('}') {
379        s.push('}');
380    }
381    Some(s)
382}
383
384fn sessions_dir() -> PathBuf {
385    crate::config::get_active_config_dir().join("sessions")
386}
387
388/// Validate a session or chain name: [a-z0-9-]{1,40}.
389pub fn validate_name(name: &str) -> Result<(), String> {
390    if name.is_empty() {
391        return Err("name cannot be empty".into());
392    }
393    if name.len() > 40 {
394        return Err(format!("invalid name '{}': must be 40 chars or less", name));
395    }
396    if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
397        return Err(format!(
398            "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
399            name
400        ));
401    }
402    Ok(())
403}
404
405/// Find a session by its assigned name (not partial ID).
406/// Iterates session headers one file at a time and returns on first match,
407/// avoiding a full directory scan when the named session appears early.
408pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
409    let dir = sessions_dir();
410    if dir.exists() {
411        for entry in std::fs::read_dir(&dir)? {
412            let entry = entry?;
413            let path = entry.path();
414            if !path.extension().is_some_and(|e| e == "json") {
415                continue;
416            }
417            if let Some(info) = parse_session_header(&path) {
418                if info.name.as_deref() == Some(name) {
419                    return Session::load(&info.id);
420                }
421            }
422        }
423    }
424    Err(std::io::Error::new(
425        std::io::ErrorKind::NotFound,
426        format!("no session named '{}'", name),
427    ))
428}
429
430/// Resolve a query string to a Session. Resolution order:
431/// 1. Chain name  2. Session name  3. Partial session ID
432pub fn resolve_session(query: &str) -> std::io::Result<Session> {
433    if let Ok(ptr) = crate::core::chain::load_chain(query) {
434        match Session::load(&ptr.head) {
435            Ok(s) => {
436                tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
437                return Ok(s);
438            }
439            Err(e) => {
440                return Err(std::io::Error::new(
441                    e.kind(),
442                    format!(
443                        "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
444                        query, ptr.head, e, query
445                    ),
446                ));
447            }
448        }
449    }
450    if let Ok(s) = find_session_by_name(query) {
451        tracing::info!("resolved '{}' via session name → {}", query, s.id);
452        return Ok(s);
453    }
454    find_session(query)
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use serde_json::json;
461
462    #[test]
463    fn test_session_new() {
464        let session = Session::new("gpt-4", "brief", Some("test prompt"));
465        
466        // Check model and thinking_level are set correctly
467        assert_eq!(session.model, "gpt-4");
468        assert_eq!(session.thinking_level, "brief");
469        assert_eq!(session.system_prompt, Some("test prompt".to_string()));
470        
471        // Check ID is non-empty
472        assert!(!session.id.is_empty());
473        
474        // Check title starts empty
475        assert_eq!(session.title, "");
476        
477        // Check tokens are 0
478        assert_eq!(session.total_input_tokens, 0);
479        assert_eq!(session.total_output_tokens, 0);
480        
481        // Check cost is 0
482        assert_eq!(session.session_cost, 0.0);
483        
484        // Check api_messages is empty
485        assert!(session.api_messages.is_empty());
486        
487        // Test without system prompt
488        let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
489        assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
490        assert_eq!(session_no_prompt.thinking_level, "normal");
491        assert_eq!(session_no_prompt.system_prompt, None);
492    }
493
494    #[test]
495    fn test_session_auto_title() {
496        let mut session = Session::new("gpt-4", "brief", None);
497        
498        // Add a user message
499        session.api_messages.push(json!({
500            "role": "user",
501            "content": "hello world"
502        }));
503        
504        // Call auto_title
505        session.auto_title();
506        
507        // Check title is set to message content
508        assert_eq!(session.title, "hello world");
509        
510        // Test it doesn't overwrite existing title
511        session.title = "existing title".to_string();
512        session.auto_title();
513        assert_eq!(session.title, "existing title");
514        
515        // Test with empty session (no messages)
516        let mut empty_session = Session::new("gpt-4", "brief", None);
517        empty_session.auto_title();
518        assert_eq!(empty_session.title, "");
519        
520        // Test with non-user message
521        let mut session_no_user = Session::new("gpt-4", "brief", None);
522        session_no_user.api_messages.push(json!({
523            "role": "assistant",
524            "content": "response"
525        }));
526        session_no_user.auto_title();
527        assert_eq!(session_no_user.title, "");
528        
529        // Test with long content (should truncate to 80 chars)
530        let mut session_long = Session::new("gpt-4", "brief", None);
531        let long_content = "a".repeat(100);
532        session_long.api_messages.push(json!({
533            "role": "user",
534            "content": long_content
535        }));
536        session_long.auto_title();
537        assert_eq!(session_long.title.len(), 80);
538        assert_eq!(session_long.title, "a".repeat(80));
539    }
540
541    #[test]
542    fn test_session_info() {
543        let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
544        
545        // Add some messages to test message count
546        session.api_messages.push(json!({
547            "role": "user",
548            "content": "test message"
549        }));
550        session.api_messages.push(json!({
551            "role": "assistant",
552            "content": "test response"
553        }));
554        
555        session.title = "Test Title".to_string();
556        session.session_cost = 0.05;
557        
558        let info = session.info();
559        
560        assert_eq!(info.id, session.id);
561        assert_eq!(info.title, "Test Title");
562        assert_eq!(info.model, "gpt-4");
563        assert_eq!(info.created_at, session.created_at);
564        assert_eq!(info.updated_at, session.updated_at);
565        assert_eq!(info.session_cost, 0.05);
566        assert_eq!(info.message_count, 2);
567    }
568
569    #[test]
570    fn test_session_info_struct() {
571        let now = Utc::now();
572        
573        let session_info = SessionInfo {
574            id: "test-id".to_string(),
575            title: "Test Title".to_string(),
576            name: None,
577            model: "gpt-4".to_string(),
578            created_at: now,
579            updated_at: now,
580            session_cost: 1.23,
581            message_count: 5,
582        };
583        
584        // Verify all fields are accessible
585        assert_eq!(session_info.id, "test-id");
586        assert_eq!(session_info.title, "Test Title");
587        assert_eq!(session_info.model, "gpt-4");
588        assert_eq!(session_info.created_at, now);
589        assert_eq!(session_info.updated_at, now);
590        assert_eq!(session_info.session_cost, 1.23);
591        assert_eq!(session_info.message_count, 5);
592    }
593
594    #[test]
595    fn test_session_serialization_round_trip() {
596        let mut session = Session::new("gpt-4-turbo", "detailed", Some("You are a helpful assistant"));
597        session.title = "Test Session".to_string();
598        session.api_messages.push(json!({"role": "user", "content": "test"}));
599        session.total_input_tokens = 100;
600        session.total_output_tokens = 200;
601        session.session_cost = 0.15;
602
603        // Serialize to JSON string
604        let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
605        
606        // Deserialize back from JSON string
607        let deserialized: Session = serde_json::from_str(&json_str).expect("Failed to deserialize session");
608
609        // Verify all fields match
610        assert_eq!(deserialized.id, session.id);
611        assert_eq!(deserialized.title, session.title);
612        assert_eq!(deserialized.model, session.model);
613        assert_eq!(deserialized.thinking_level, session.thinking_level);
614        assert_eq!(deserialized.system_prompt, session.system_prompt);
615        assert_eq!(deserialized.created_at, session.created_at);
616        assert_eq!(deserialized.updated_at, session.updated_at);
617        assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
618        assert_eq!(deserialized.total_output_tokens, session.total_output_tokens);
619        assert_eq!(deserialized.session_cost, session.session_cost);
620        assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
621        assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
622    }
623
624    #[test] 
625    fn test_session_serialization_preserves_all_fields() {
626        let mut session = Session::new("claude-3-opus", "comprehensive", Some("Custom system prompt"));
627        session.title = "Complex Session".to_string();
628        
629        // Add multiple messages
630        session.api_messages.push(json!({"role": "user", "content": "First message"}));
631        session.api_messages.push(json!({"role": "assistant", "content": "First response"}));
632        session.api_messages.push(json!({"role": "user", "content": "Second message"}));
633        
634        // Set token counts and cost
635        session.total_input_tokens = 1500;
636        session.total_output_tokens = 2500;
637        session.session_cost = 0.75;
638
639        // Serialize and deserialize
640        let json_str = serde_json::to_string(&session).unwrap();
641        let restored: Session = serde_json::from_str(&json_str).unwrap();
642
643        // Verify every field is preserved
644        assert_eq!(restored.id, session.id);
645        assert_eq!(restored.title, "Complex Session");
646        assert_eq!(restored.model, "claude-3-opus");
647        assert_eq!(restored.thinking_level, "comprehensive");
648        assert_eq!(restored.system_prompt.as_ref().unwrap(), "Custom system prompt");
649        assert_eq!(restored.created_at, session.created_at);
650        assert_eq!(restored.updated_at, session.updated_at);
651        assert_eq!(restored.total_input_tokens, 1500);
652        assert_eq!(restored.total_output_tokens, 2500);
653        assert_eq!(restored.session_cost, 0.75);
654        assert_eq!(restored.api_messages.len(), 3);
655        assert_eq!(restored.api_messages[0]["role"], "user");
656        assert_eq!(restored.api_messages[0]["content"], "First message");
657        assert_eq!(restored.api_messages[1]["role"], "assistant");
658        assert_eq!(restored.api_messages[2]["content"], "Second message");
659    }
660
661    #[test]
662    fn test_session_info_from_session_with_messages() {
663        let mut session = Session::new("gpt-3.5-turbo", "normal", None);
664        
665        // Add exactly 3 messages
666        session.api_messages.push(json!({"role": "user", "content": "message 1"}));
667        session.api_messages.push(json!({"role": "assistant", "content": "response 1"}));
668        session.api_messages.push(json!({"role": "user", "content": "message 2"}));
669        
670        let info = session.info();
671        
672        // Verify message count is exactly 3
673        assert_eq!(info.message_count, 3);
674        assert_eq!(info.id, session.id);
675        assert_eq!(info.model, "gpt-3.5-turbo");
676    }
677
678    #[test] 
679    fn test_session_auto_title_truncation() {
680        let mut session = Session::new("gpt-4", "brief", None);
681        
682        // Create a user message with exactly 200 characters
683        let long_content = "a".repeat(200);
684        session.api_messages.push(json!({
685            "role": "user",
686            "content": long_content
687        }));
688        
689        session.auto_title();
690        
691        // Verify title is exactly 80 characters
692        assert_eq!(session.title.len(), 80);
693        assert_eq!(session.title, "a".repeat(80));
694    }
695
696    #[test]
697    fn test_session_auto_title_skips_non_user_messages() {
698        let mut session = Session::new("gpt-4", "brief", None);
699        
700        // Push only an assistant message (no user messages)
701        session.api_messages.push(json!({
702            "role": "assistant", 
703            "content": "This should be ignored for auto title"
704        }));
705        
706        session.auto_title();
707        
708        // Verify title stays empty since there are no user messages
709        assert_eq!(session.title, "");
710        
711        // Test with system message too
712        session.api_messages.push(json!({
713            "role": "system",
714            "content": "System message should also be ignored"
715        }));
716        
717        session.auto_title();
718        assert_eq!(session.title, "");
719    }
720
721    #[test]
722    fn test_session_new_generates_unique_ids() {
723        let session1 = Session::new("gpt-4", "brief", None);
724        let session2 = Session::new("gpt-4", "brief", None);
725        
726        // Verify IDs are different
727        assert_ne!(session1.id, session2.id);
728        assert!(!session1.id.is_empty());
729        assert!(!session2.id.is_empty());
730    }
731
732    #[test]
733    fn test_session_new_timestamps() {
734        let before = Utc::now();
735        let session = Session::new("gpt-4", "brief", None);
736        let after = Utc::now();
737        
738        // Verify created_at and updated_at are close to now (within 2 seconds)
739        let created_diff = (session.created_at - before).num_seconds().abs();
740        let updated_diff = (session.updated_at - before).num_seconds().abs();
741        
742        assert!(created_diff <= 2, "created_at should be within 2 seconds of now");
743        assert!(updated_diff <= 2, "updated_at should be within 2 seconds of now");
744        
745        // Verify both timestamps are the same for new sessions
746        assert_eq!(session.created_at, session.updated_at);
747        
748        // Verify timestamps are not in the future
749        assert!(session.created_at <= after);
750        assert!(session.updated_at <= after);
751    }
752
753    #[test]
754    fn test_validate_name() {
755        assert!(validate_name("work").is_ok());
756        assert!(validate_name("my-project-2").is_ok());
757        assert!(validate_name("a").is_ok());
758        assert!(validate_name(&"a".repeat(40)).is_ok());
759
760        assert!(validate_name("").is_err());
761        assert!(validate_name(&"a".repeat(41)).is_err());
762        assert!(validate_name("UPPER").is_err());
763        assert!(validate_name("has space").is_err());
764        assert!(validate_name("under_score").is_err());
765        assert!(validate_name("dots.bad").is_err());
766
767        let err = validate_name("Bad").unwrap_err();
768        assert!(err.contains("Bad"));
769        assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
770    }
771
772    #[test]
773    fn test_clear_name() {
774        let mut s = Session::new("m", "brief", None);
775        s.name = Some("foo".into());
776        s.clear_name();
777        assert_eq!(s.name, None);
778    }
779}