Skip to main content

agent_sdk/session/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use tokio::fs;
4use tokio::io::AsyncWriteExt;
5use tracing::warn;
6use uuid::Uuid;
7
8use crate::error::{AgentError, Result};
9
10/// Information about a past session.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionInfo {
13    /// Unique session identifier (UUID).
14    pub session_id: String,
15    /// Display title: custom title, auto-generated summary, or first prompt.
16    pub summary: String,
17    /// Last modified time in milliseconds since epoch.
18    pub last_modified: u64,
19    /// Session file size in bytes.
20    pub file_size: u64,
21    /// User-set session title.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub custom_title: Option<String>,
24    /// First meaningful user prompt in the session.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub first_prompt: Option<String>,
27    /// Git branch at the end of the session.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub git_branch: Option<String>,
30    /// Working directory for the session.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub cwd: Option<String>,
33}
34
35/// A session message from a transcript.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SessionMessage {
38    #[serde(rename = "type")]
39    pub message_type: String,
40    pub uuid: String,
41    pub session_id: String,
42    pub message: serde_json::Value,
43    pub parent_tool_use_id: Option<String>,
44}
45
46/// Manages session state and persistence.
47#[derive(Debug)]
48pub struct Session {
49    pub id: String,
50    pub cwd: String,
51    pub messages: Vec<serde_json::Value>,
52    /// Optional override for the home directory (used in tests).
53    home_override: Option<PathBuf>,
54}
55
56impl Session {
57    /// Create a new session with a generated ID.
58    pub fn new(cwd: impl Into<String>) -> Self {
59        Self {
60            id: Uuid::new_v4().to_string(),
61            cwd: cwd.into(),
62            messages: Vec::new(),
63            home_override: None,
64        }
65    }
66
67    /// Create a session with a specific ID.
68    pub fn with_id(id: impl Into<String>, cwd: impl Into<String>) -> Self {
69        Self {
70            id: id.into(),
71            cwd: cwd.into(),
72            messages: Vec::new(),
73            home_override: None,
74        }
75    }
76
77    /// Set an explicit home directory override (useful for testing).
78    pub fn with_home(mut self, home: impl Into<PathBuf>) -> Self {
79        self.home_override = Some(home.into());
80        self
81    }
82
83    /// Get the path where sessions are stored for a given working directory.
84    pub fn sessions_dir(cwd: &str) -> PathBuf {
85        Self::sessions_dir_with_home(cwd, home_dir_or_tmp())
86    }
87
88    /// Get the sessions directory using an explicit home path.
89    pub fn sessions_dir_with_home(cwd: &str, home: PathBuf) -> PathBuf {
90        let encoded_cwd = encode_path(cwd);
91        home.join(".claude").join("projects").join(encoded_cwd)
92    }
93
94    /// Get the file path for this session's transcript.
95    pub fn transcript_path(&self) -> PathBuf {
96        let home = self.home_override.clone().unwrap_or_else(home_dir_or_tmp);
97        Self::sessions_dir_with_home(&self.cwd, home)
98            .join(format!("{}.jsonl", self.id))
99    }
100
101    /// Append a JSON message to the transcript file on disk.
102    ///
103    /// Creates the sessions directory and file if they don't exist.
104    /// The message is serialized as a single JSON line followed by a newline.
105    pub async fn append_message(&self, message: &serde_json::Value) -> Result<()> {
106        let path = self.transcript_path();
107
108        // Ensure the parent directory exists.
109        if let Some(parent) = path.parent() {
110            fs::create_dir_all(parent).await?;
111        }
112
113        let mut line = serde_json::to_string(message)?;
114        line.push('\n');
115
116        let mut file = fs::OpenOptions::new()
117            .create(true)
118            .append(true)
119            .open(&path)
120            .await?;
121
122        // Restrict session files to owner-only on Unix.
123        #[cfg(unix)]
124        {
125            use std::os::unix::fs::PermissionsExt;
126            let perms = std::fs::Permissions::from_mode(0o600);
127            fs::set_permissions(&path, perms).await?;
128        }
129
130        file.write_all(line.as_bytes()).await?;
131        file.flush().await?;
132
133        Ok(())
134    }
135
136    /// Load all messages from the transcript file.
137    ///
138    /// Returns an empty vec if the file does not exist.
139    /// Skips any lines that fail to parse as JSON, logging a warning.
140    pub async fn load_messages(&self) -> Result<Vec<serde_json::Value>> {
141        let path = self.transcript_path();
142
143        if !path.exists() {
144            return Ok(Vec::new());
145        }
146
147        let contents = fs::read_to_string(&path).await?;
148        let mut messages = Vec::new();
149
150        for (i, line) in contents.lines().enumerate() {
151            let trimmed = line.trim();
152            if trimmed.is_empty() {
153                continue;
154            }
155            match serde_json::from_str::<serde_json::Value>(trimmed) {
156                Ok(value) => messages.push(value),
157                Err(e) => {
158                    warn!(
159                        "Skipping malformed JSON on line {} of {}: {}",
160                        i + 1,
161                        path.display(),
162                        e
163                    );
164                }
165            }
166        }
167
168        Ok(messages)
169    }
170}
171
172/// List sessions for a given directory, sorted by last_modified descending.
173///
174/// Scans `~/.claude/projects/<encoded-dir>/` for `.jsonl` files. For each file,
175/// reads the first few lines to extract the first user prompt and any custom title.
176///
177/// * `dir` - working directory whose sessions to list; if `None`, uses the
178///   current working directory.
179/// * `limit` - maximum number of sessions to return; if `None`, returns all.
180pub async fn list_sessions(
181    dir: Option<&str>,
182    limit: Option<usize>,
183) -> Result<Vec<SessionInfo>> {
184    list_sessions_with_home(dir, limit, home_dir_or_tmp()).await
185}
186
187/// Like [`list_sessions`] but with an explicit home directory.
188pub async fn list_sessions_with_home(
189    dir: Option<&str>,
190    limit: Option<usize>,
191    home: PathBuf,
192) -> Result<Vec<SessionInfo>> {
193    let cwd = resolve_cwd(dir)?;
194    let sessions_dir = Session::sessions_dir_with_home(&cwd, home);
195
196    if !sessions_dir.exists() {
197        return Ok(Vec::new());
198    }
199
200    let mut entries = fs::read_dir(&sessions_dir).await?;
201    let mut infos: Vec<SessionInfo> = Vec::new();
202
203    while let Some(entry) = entries.next_entry().await? {
204        let path = entry.path();
205
206        // Only consider .jsonl files.
207        let ext = path.extension().and_then(|e| e.to_str());
208        if ext != Some("jsonl") {
209            continue;
210        }
211
212        let session_id = match path.file_stem().and_then(|s| s.to_str()) {
213            Some(stem) => stem.to_string(),
214            None => continue,
215        };
216
217        let metadata = match fs::metadata(&path).await {
218            Ok(m) => m,
219            Err(_) => continue,
220        };
221
222        let last_modified = metadata
223            .modified()
224            .ok()
225            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
226            .map(|d| d.as_millis() as u64)
227            .unwrap_or(0);
228
229        let file_size = metadata.len();
230
231        // Read the file to extract the first user prompt and any custom title.
232        let (first_prompt, custom_title) = extract_session_metadata(&path).await;
233
234        let summary = custom_title
235            .clone()
236            .or_else(|| first_prompt.clone())
237            .unwrap_or_else(|| "(empty session)".to_string());
238
239        infos.push(SessionInfo {
240            session_id,
241            summary,
242            last_modified,
243            file_size,
244            custom_title,
245            first_prompt,
246            git_branch: None,
247            cwd: Some(cwd.clone()),
248        });
249    }
250
251    // Sort by last_modified descending (newest first).
252    infos.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
253
254    if let Some(limit) = limit {
255        infos.truncate(limit);
256    }
257
258    Ok(infos)
259}
260
261/// Get messages from a specific session, with optional pagination.
262///
263/// * `session_id` - the UUID of the session to read.
264/// * `dir` - working directory; if `None`, uses the current working directory.
265/// * `limit` - max number of messages to return; if `None`, returns all (after offset).
266/// * `offset` - number of messages to skip from the start; if `None`, starts at 0.
267pub async fn get_session_messages(
268    session_id: &str,
269    dir: Option<&str>,
270    limit: Option<usize>,
271    offset: Option<usize>,
272) -> Result<Vec<SessionMessage>> {
273    get_session_messages_with_home(session_id, dir, limit, offset, home_dir_or_tmp()).await
274}
275
276/// Like [`get_session_messages`] but with an explicit home directory.
277pub async fn get_session_messages_with_home(
278    session_id: &str,
279    dir: Option<&str>,
280    limit: Option<usize>,
281    offset: Option<usize>,
282    home: PathBuf,
283) -> Result<Vec<SessionMessage>> {
284    let cwd = resolve_cwd(dir)?;
285    let session = Session::with_id(session_id, &cwd).with_home(&home);
286    let path = session.transcript_path();
287
288    if !path.exists() {
289        return Err(AgentError::SessionNotFound(session_id.to_string()));
290    }
291
292    let contents = fs::read_to_string(&path).await?;
293    let offset = offset.unwrap_or(0);
294
295    let mut messages: Vec<SessionMessage> = Vec::new();
296
297    for (i, line) in contents.lines().enumerate() {
298        let trimmed = line.trim();
299        if trimmed.is_empty() {
300            continue;
301        }
302
303        // Apply offset: skip the first `offset` non-empty lines.
304        if i < offset {
305            continue;
306        }
307
308        // Apply limit.
309        if let Some(limit) = limit {
310            if messages.len() >= limit {
311                break;
312            }
313        }
314
315        match serde_json::from_str::<serde_json::Value>(trimmed) {
316            Ok(value) => {
317                let msg = SessionMessage {
318                    message_type: value
319                        .get("type")
320                        .and_then(|v| v.as_str())
321                        .unwrap_or("unknown")
322                        .to_string(),
323                    uuid: value
324                        .get("uuid")
325                        .and_then(|v| v.as_str())
326                        .unwrap_or("")
327                        .to_string(),
328                    session_id: value
329                        .get("session_id")
330                        .and_then(|v| v.as_str())
331                        .unwrap_or(session_id)
332                        .to_string(),
333                    message: value,
334                    parent_tool_use_id: None,
335                };
336                messages.push(msg);
337            }
338            Err(e) => {
339                warn!(
340                    "Skipping malformed JSON on line {} of {}: {}",
341                    i + 1,
342                    path.display(),
343                    e
344                );
345            }
346        }
347    }
348
349    Ok(messages)
350}
351
352/// Find the most recently modified session in the given directory.
353///
354/// Useful for the "continue" option: resumes the last active session.
355/// Returns `None` if no sessions exist.
356pub async fn find_most_recent_session(dir: Option<&str>) -> Result<Option<SessionInfo>> {
357    let sessions = list_sessions(dir, Some(1)).await?;
358    Ok(sessions.into_iter().next())
359}
360
361/// Like [`find_most_recent_session`] but with an explicit home directory.
362pub async fn find_most_recent_session_with_home(dir: Option<&str>, home: PathBuf) -> Result<Option<SessionInfo>> {
363    let sessions = list_sessions_with_home(dir, Some(1), home).await?;
364    Ok(sessions.into_iter().next())
365}
366
367// ---------------------------------------------------------------------------
368// Internal helpers
369// ---------------------------------------------------------------------------
370
371/// Encode a filesystem path for use as a directory name.
372/// Every non-alphanumeric character is replaced with `-`.
373fn encode_path(path: &str) -> String {
374    path.chars()
375        .map(|c| if c.is_alphanumeric() { c } else { '-' })
376        .collect()
377}
378
379/// Return the user's home directory, falling back to `/tmp` if `HOME` is unset.
380fn home_dir_or_tmp() -> PathBuf {
381    std::env::var("HOME")
382        .ok()
383        .map(PathBuf::from)
384        .unwrap_or_else(|| PathBuf::from("/tmp"))
385}
386
387/// Resolve the working directory: use the provided `dir` or fall back to the
388/// current working directory.
389fn resolve_cwd(dir: Option<&str>) -> Result<String> {
390    match dir {
391        Some(d) => Ok(d.to_string()),
392        None => std::env::current_dir()
393            .map(|p| p.to_string_lossy().into_owned())
394            .map_err(|e| AgentError::Io(e)),
395    }
396}
397
398/// Read a JSONL transcript file and extract the first user prompt text and
399/// any custom session title.
400///
401/// We only read up to 50 lines to keep this fast for large transcripts.
402async fn extract_session_metadata(path: &PathBuf) -> (Option<String>, Option<String>) {
403    let contents = match fs::read_to_string(path).await {
404        Ok(c) => c,
405        Err(_) => return (None, None),
406    };
407
408    let mut first_prompt: Option<String> = None;
409    let mut custom_title: Option<String> = None;
410
411    for line in contents.lines().take(50) {
412        let trimmed = line.trim();
413        if trimmed.is_empty() {
414            continue;
415        }
416
417        let value: serde_json::Value = match serde_json::from_str(trimmed) {
418            Ok(v) => v,
419            Err(_) => continue,
420        };
421
422        // Look for a custom title field (set by the user or system).
423        if let Some(title) = value.get("customTitle").and_then(|v| v.as_str()) {
424            if !title.is_empty() {
425                custom_title = Some(title.to_string());
426            }
427        }
428        if let Some(title) = value.get("custom_title").and_then(|v| v.as_str()) {
429            if !title.is_empty() {
430                custom_title = Some(title.to_string());
431            }
432        }
433
434        // Extract first user prompt if we haven't found one yet.
435        if first_prompt.is_none() {
436            if let Some("user") = value.get("type").and_then(|v| v.as_str()) {
437                if let Some(content) = value.get("content") {
438                    let text = extract_text_from_content(content);
439                    if !text.is_empty() {
440                        // Truncate long prompts for display.
441                        let truncated = if text.len() > 200 {
442                            format!("{}...", &text[..200])
443                        } else {
444                            text
445                        };
446                        first_prompt = Some(truncated);
447                    }
448                }
449            }
450        }
451
452        // If we have both, stop early.
453        if first_prompt.is_some() && custom_title.is_some() {
454            break;
455        }
456    }
457
458    (first_prompt, custom_title)
459}
460
461/// Extract plain text from a message's `content` field.
462///
463/// Content may be a string, or an array of content blocks (each with a `type`
464/// and `text` field).
465fn extract_text_from_content(content: &serde_json::Value) -> String {
466    if let Some(s) = content.as_str() {
467        return s.to_string();
468    }
469
470    if let Some(blocks) = content.as_array() {
471        let texts: Vec<&str> = blocks
472            .iter()
473            .filter_map(|block| {
474                if block.get("type").and_then(|t| t.as_str()) == Some("text") {
475                    block.get("text").and_then(|t| t.as_str())
476                } else {
477                    None
478                }
479            })
480            .collect();
481        return texts.join(" ");
482    }
483
484    String::new()
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use serde_json::json;
491    use tempfile::TempDir;
492
493    /// Helper: create a Session whose sessions dir lives inside a temp directory
494    /// using the home_override mechanism (no env var mutation needed).
495    fn session_in_tmp(tmp: &TempDir) -> Session {
496        Session::new("/test/project").with_home(tmp.path())
497    }
498
499    #[tokio::test]
500    async fn test_append_and_load_roundtrip() {
501        let tmp = TempDir::new().unwrap();
502        let session = session_in_tmp(&tmp);
503
504        let msg1 = json!({"type": "user", "content": "hello"});
505        let msg2 = json!({"type": "assistant", "content": "world"});
506
507        session.append_message(&msg1).await.unwrap();
508        session.append_message(&msg2).await.unwrap();
509
510        let loaded = session.load_messages().await.unwrap();
511        assert_eq!(loaded.len(), 2);
512        assert_eq!(loaded[0]["content"], "hello");
513        assert_eq!(loaded[1]["content"], "world");
514    }
515
516    #[tokio::test]
517    async fn test_load_messages_empty_file() {
518        let tmp = TempDir::new().unwrap();
519        let session = session_in_tmp(&tmp);
520
521        // No file written yet.
522        let loaded = session.load_messages().await.unwrap();
523        assert!(loaded.is_empty());
524    }
525
526    #[tokio::test]
527    async fn test_transcript_path_encoding() {
528        let session = Session::with_id("abc-123", "/home/user/my project");
529        let path = session.transcript_path();
530        let path_str = path.to_string_lossy();
531
532        // The cwd should be encoded: slashes and spaces become dashes.
533        assert!(path_str.contains("-home-user-my-project"));
534        assert!(path_str.ends_with("abc-123.jsonl"));
535    }
536
537    #[tokio::test]
538    async fn test_list_sessions_and_find_most_recent() {
539        let tmp = TempDir::new().unwrap();
540        let home = tmp.path().to_path_buf();
541
542        let cwd = "/test/project";
543
544        // Create two sessions with a small delay between them.
545        let s1 = Session::with_id("session-1", cwd).with_home(&home);
546        let s2 = Session::with_id("session-2", cwd).with_home(&home);
547
548        s1.append_message(&json!({"type": "user", "content": [{"type": "text", "text": "first prompt"}]}))
549            .await
550            .unwrap();
551
552        // Small delay so modified times differ.
553        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
554
555        s2.append_message(&json!({"type": "user", "content": "second session prompt"}))
556            .await
557            .unwrap();
558
559        let sessions = list_sessions_with_home(Some(cwd), None, home.clone()).await.unwrap();
560        assert_eq!(sessions.len(), 2);
561
562        // Newest first.
563        assert_eq!(sessions[0].session_id, "session-2");
564        assert_eq!(sessions[1].session_id, "session-1");
565
566        // Test limit.
567        let sessions = list_sessions_with_home(Some(cwd), Some(1), home.clone()).await.unwrap();
568        assert_eq!(sessions.len(), 1);
569        assert_eq!(sessions[0].session_id, "session-2");
570
571        // Test find_most_recent_session.
572        let recent = find_most_recent_session_with_home(Some(cwd), home.clone()).await.unwrap();
573        assert!(recent.is_some());
574        assert_eq!(recent.unwrap().session_id, "session-2");
575    }
576
577    #[tokio::test]
578    async fn test_get_session_messages_pagination() {
579        let tmp = TempDir::new().unwrap();
580        let home = tmp.path().to_path_buf();
581
582        let cwd = "/test/project";
583        let session = Session::with_id("paginated", cwd).with_home(&home);
584
585        for i in 0..10 {
586            session
587                .append_message(&json!({"type": "user", "content": format!("msg {}", i)}))
588                .await
589                .unwrap();
590        }
591
592        // Read all.
593        let all = get_session_messages_with_home("paginated", Some(cwd), None, None, home.clone())
594            .await
595            .unwrap();
596        assert_eq!(all.len(), 10);
597
598        // With offset and limit.
599        let page = get_session_messages_with_home("paginated", Some(cwd), Some(3), Some(2), home.clone())
600            .await
601            .unwrap();
602        assert_eq!(page.len(), 3);
603        assert_eq!(page[0].message["content"], "msg 2");
604
605        // Non-existent session.
606        let err = get_session_messages_with_home("nonexistent", Some(cwd), None, None, home.clone()).await;
607        assert!(err.is_err());
608    }
609}