1use crate::error::{ClaudeSDKError, Result};
2use crate::session_store::{
3 project_key_for_directory, SessionKey, SessionStoreEntry, SessionStoreHandle,
4};
5use crate::session_summary::{fold_session_summary, summary_entry_to_sdk_info};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct SDKSessionInfo {
11 pub session_id: String,
12 pub summary: String,
13 pub last_modified: i64,
14 pub file_size: Option<i64>,
15 pub custom_title: Option<String>,
16 pub first_prompt: Option<String>,
17 pub git_branch: Option<String>,
18 pub cwd: Option<String>,
19 pub tag: Option<String>,
20 pub created_at: Option<i64>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct SDKSessionMessage {
25 pub r#type: String,
26 pub uuid: String,
27 pub session_id: String,
28 pub message: serde_json::Value,
29 pub parent_tool_use_id: Option<String>,
30}
31
32pub async fn rename_session_via_store(
33 session_store: &SessionStoreHandle,
34 session_id: &str,
35 title: &str,
36 directory: Option<&str>,
37) -> Result<()> {
38 validate_session_id(session_id)?;
39 let title = title.trim();
40 if title.is_empty() {
41 return Err(ClaudeSDKError::Session(
42 "title must be non-empty".to_string(),
43 ));
44 }
45 let project_key = project_key_for_directory(directory.map(Path::new));
46 session_store
47 .append(
48 SessionKey {
49 project_key,
50 session_id: session_id.to_string(),
51 subpath: None,
52 },
53 vec![metadata_entry(
54 "custom-title",
55 session_id,
56 [("customTitle", serde_json::Value::String(title.to_string()))],
57 )],
58 )
59 .await
60}
61
62pub async fn tag_session_via_store(
63 session_store: &SessionStoreHandle,
64 session_id: &str,
65 tag: Option<&str>,
66 directory: Option<&str>,
67) -> Result<()> {
68 validate_session_id(session_id)?;
69 let tag = tag.map(sanitize_tag).transpose()?;
70 let project_key = project_key_for_directory(directory.map(Path::new));
71 session_store
72 .append(
73 SessionKey {
74 project_key,
75 session_id: session_id.to_string(),
76 subpath: None,
77 },
78 vec![metadata_entry(
79 "tag",
80 session_id,
81 [("tag", serde_json::Value::String(tag.unwrap_or_default()))],
82 )],
83 )
84 .await
85}
86
87pub async fn delete_session_via_store(
88 session_store: &SessionStoreHandle,
89 session_id: &str,
90 directory: Option<&str>,
91) -> Result<()> {
92 validate_session_id(session_id)?;
93 let project_key = project_key_for_directory(directory.map(Path::new));
94 session_store
95 .delete(SessionKey {
96 project_key,
97 session_id: session_id.to_string(),
98 subpath: None,
99 })
100 .await
101}
102
103pub async fn list_sessions_from_store(
104 session_store: &SessionStoreHandle,
105 directory: Option<&str>,
106 limit: Option<usize>,
107 offset: usize,
108) -> Result<Vec<SDKSessionInfo>> {
109 let project_path = canonical_project_path(directory);
110 let project_key = project_key_for_directory(Some(Path::new(&project_path)));
111 let listing = session_store.list_sessions(&project_key).await?;
112 let summaries = session_store
113 .list_session_summaries(&project_key)
114 .await
115 .unwrap_or_default();
116
117 let mut infos = Vec::new();
118 let mut covered = std::collections::HashSet::new();
119 let known_mtimes = listing
120 .iter()
121 .map(|entry| (entry.session_id.clone(), entry.mtime))
122 .collect::<std::collections::HashMap<_, _>>();
123
124 for summary in summaries {
125 if let Some(known_mtime) = known_mtimes.get(&summary.session_id) {
126 if summary.mtime < *known_mtime {
127 continue;
128 }
129 }
130 covered.insert(summary.session_id.clone());
131 if let Some(info) = summary_entry_to_sdk_info(&summary, Some(&project_path)) {
132 infos.push(info);
133 }
134 }
135
136 for entry in listing {
137 if covered.contains(&entry.session_id) {
138 continue;
139 }
140 match derive_session_info_from_store(
141 session_store,
142 &project_key,
143 &project_path,
144 &entry.session_id,
145 )
146 .await
147 {
148 Ok(Some(mut info)) => {
149 info.last_modified = entry.mtime;
150 infos.push(info);
151 }
152 Ok(None) => {}
153 Err(_) => infos.push(degraded_session_info(&entry.session_id, entry.mtime)),
154 }
155 }
156
157 Ok(sort_limit_offset(infos, limit, offset))
158}
159
160fn degraded_session_info(session_id: &str, last_modified: i64) -> SDKSessionInfo {
161 SDKSessionInfo {
162 session_id: session_id.to_string(),
163 summary: String::new(),
164 last_modified,
165 file_size: None,
166 custom_title: None,
167 first_prompt: None,
168 git_branch: None,
169 cwd: None,
170 tag: None,
171 created_at: None,
172 }
173}
174
175pub async fn get_session_info_from_store(
176 session_store: &SessionStoreHandle,
177 session_id: &str,
178 directory: Option<&str>,
179) -> Result<Option<SDKSessionInfo>> {
180 if !is_uuid(session_id) {
181 return Ok(None);
182 }
183 let project_path = canonical_project_path(directory);
184 let project_key = project_key_for_directory(Some(Path::new(&project_path)));
185 derive_session_info_from_store(session_store, &project_key, &project_path, session_id).await
186}
187
188pub async fn get_session_messages_from_store(
189 session_store: &SessionStoreHandle,
190 session_id: &str,
191 directory: Option<&str>,
192 limit: Option<usize>,
193 offset: usize,
194) -> Result<Vec<SDKSessionMessage>> {
195 if !is_uuid(session_id) {
196 return Ok(Vec::new());
197 }
198 let project_key = project_key_for_directory(directory.map(Path::new));
199 let key = SessionKey {
200 project_key,
201 session_id: session_id.to_string(),
202 subpath: None,
203 };
204 let Some(entries) = session_store.load(key).await? else {
205 return Ok(Vec::new());
206 };
207 Ok(entries_to_session_messages(
208 session_id, entries, limit, offset,
209 ))
210}
211
212pub async fn list_subagents_from_store(
213 session_store: &SessionStoreHandle,
214 session_id: &str,
215 directory: Option<&str>,
216) -> Result<Vec<String>> {
217 if !is_uuid(session_id) {
218 return Ok(Vec::new());
219 }
220 let project_key = project_key_for_directory(directory.map(Path::new));
221 let subkeys = session_store
222 .list_subkeys(crate::session_store::SessionListSubkeysKey {
223 project_key,
224 session_id: session_id.to_string(),
225 })
226 .await?;
227 let mut seen = std::collections::HashSet::new();
228 let mut ids = Vec::new();
229 for subkey in subkeys {
230 if let Some(agent_id) = subagent_id_from_subkey(&subkey) {
231 if seen.insert(agent_id.to_string()) {
232 ids.push(agent_id.to_string());
233 }
234 }
235 }
236 Ok(ids)
237}
238
239pub async fn get_subagent_messages_from_store(
240 session_store: &SessionStoreHandle,
241 session_id: &str,
242 agent_id: &str,
243 directory: Option<&str>,
244 limit: Option<usize>,
245 offset: usize,
246) -> Result<Vec<SDKSessionMessage>> {
247 if !is_uuid(session_id) || agent_id.is_empty() {
248 return Ok(Vec::new());
249 }
250 let project_key = project_key_for_directory(directory.map(Path::new));
251 let subpath = find_subagent_subpath(session_store, &project_key, session_id, agent_id).await?;
252 let Some(entries) = session_store
253 .load(SessionKey {
254 project_key,
255 session_id: session_id.to_string(),
256 subpath: Some(subpath),
257 })
258 .await?
259 else {
260 return Ok(Vec::new());
261 };
262 let entries = entries
263 .into_iter()
264 .filter(|entry| {
265 entry.get("type").and_then(|value| value.as_str()) != Some("agent_metadata")
266 })
267 .collect();
268 Ok(entries_to_session_messages(
269 session_id, entries, limit, offset,
270 ))
271}
272
273async fn find_subagent_subpath(
274 session_store: &SessionStoreHandle,
275 project_key: &str,
276 session_id: &str,
277 agent_id: &str,
278) -> Result<String> {
279 let target = format!("agent-{agent_id}");
280 let subkeys = session_store
281 .list_subkeys(crate::session_store::SessionListSubkeysKey {
282 project_key: project_key.to_string(),
283 session_id: session_id.to_string(),
284 })
285 .await
286 .unwrap_or_default();
287 Ok(subkeys
288 .into_iter()
289 .find(|subkey| {
290 subkey.starts_with("subagents/") && subkey.rsplit('/').next() == Some(target.as_str())
291 })
292 .unwrap_or_else(|| format!("subagents/{target}")))
293}
294
295async fn derive_session_info_from_store(
296 session_store: &SessionStoreHandle,
297 project_key: &str,
298 project_path: &str,
299 session_id: &str,
300) -> Result<Option<SDKSessionInfo>> {
301 let key = SessionKey {
302 project_key: project_key.to_string(),
303 session_id: session_id.to_string(),
304 subpath: None,
305 };
306 let Some(entries) = session_store.load(key.clone()).await? else {
307 return Ok(None);
308 };
309 if entries.is_empty() {
310 return Ok(None);
311 }
312 let summary = fold_session_summary(None, &key, &entries);
313 Ok(summary_entry_to_sdk_info(&summary, Some(project_path)))
314}
315
316fn entries_to_session_messages(
317 session_id: &str,
318 entries: Vec<SessionStoreEntry>,
319 limit: Option<usize>,
320 offset: usize,
321) -> Vec<SDKSessionMessage> {
322 entries
323 .into_iter()
324 .filter_map(|entry| session_message_from_entry(session_id, entry))
325 .skip(offset)
326 .take(limit.filter(|limit| *limit > 0).unwrap_or(usize::MAX))
327 .collect()
328}
329
330fn session_message_from_entry(
331 fallback_session_id: &str,
332 entry: SessionStoreEntry,
333) -> Option<SDKSessionMessage> {
334 let message_type = entry.get("type")?.as_str()?;
335 if message_type != "user" && message_type != "assistant" {
336 return None;
337 }
338 let uuid = entry.get("uuid")?.as_str()?.to_string();
339 let message = entry.get("message")?.clone();
340 Some(SDKSessionMessage {
341 r#type: message_type.to_string(),
342 uuid,
343 session_id: entry
344 .get("session_id")
345 .and_then(|value| value.as_str())
346 .unwrap_or(fallback_session_id)
347 .to_string(),
348 message,
349 parent_tool_use_id: entry
350 .get("parent_tool_use_id")
351 .and_then(|value| value.as_str())
352 .map(String::from),
353 })
354}
355
356fn subagent_id_from_subkey(subkey: &str) -> Option<&str> {
357 if !subkey.starts_with("subagents/") {
358 return None;
359 }
360 subkey.rsplit('/').next()?.strip_prefix("agent-")
361}
362
363fn sort_limit_offset(
364 mut infos: Vec<SDKSessionInfo>,
365 limit: Option<usize>,
366 offset: usize,
367) -> Vec<SDKSessionInfo> {
368 infos.sort_by_key(|info| std::cmp::Reverse(info.last_modified));
369 let infos = if offset > 0 {
370 infos.into_iter().skip(offset).collect()
371 } else {
372 infos
373 };
374 if let Some(limit) = limit.filter(|limit| *limit > 0) {
375 infos.into_iter().take(limit).collect()
376 } else {
377 infos
378 }
379}
380
381fn canonical_project_path(directory: Option<&str>) -> String {
382 let path = directory.map(Path::new).unwrap_or_else(|| Path::new("."));
383 let absolute = if path.is_absolute() {
384 path.to_path_buf()
385 } else {
386 std::env::current_dir()
387 .unwrap_or_else(|_| ".".into())
388 .join(path)
389 };
390 std::fs::canonicalize(&absolute)
391 .unwrap_or(absolute)
392 .to_string_lossy()
393 .to_string()
394}
395
396fn is_uuid(value: &str) -> bool {
397 uuid::Uuid::parse_str(value).is_ok()
398}
399
400fn validate_session_id(session_id: &str) -> Result<()> {
401 if is_uuid(session_id) {
402 Ok(())
403 } else {
404 Err(ClaudeSDKError::Session(format!(
405 "Invalid session_id: {session_id}"
406 )))
407 }
408}
409
410fn sanitize_tag(tag: &str) -> Result<String> {
411 let sanitized = tag
412 .chars()
413 .filter(|ch| !ch.is_control())
414 .collect::<String>()
415 .trim()
416 .to_string();
417 if sanitized.is_empty() {
418 Err(ClaudeSDKError::Session(
419 "tag must be non-empty (use None to clear)".to_string(),
420 ))
421 } else {
422 Ok(sanitized)
423 }
424}
425
426fn metadata_entry<const N: usize>(
427 entry_type: &str,
428 session_id: &str,
429 fields: [(&str, serde_json::Value); N],
430) -> SessionStoreEntry {
431 let mut entry = serde_json::Map::new();
432 entry.insert("type".to_string(), serde_json::json!(entry_type));
433 entry.insert("sessionId".to_string(), serde_json::json!(session_id));
434 entry.insert(
435 "uuid".to_string(),
436 serde_json::json!(uuid::Uuid::new_v4().to_string()),
437 );
438 entry.insert(
439 "timestamp".to_string(),
440 serde_json::json!(chrono::Utc::now().to_rfc3339()),
441 );
442 for (key, value) in fields {
443 entry.insert(key.to_string(), value);
444 }
445 entry
446}