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