1use std::fs;
99use std::io::{BufRead, BufReader};
100use std::path::{Path, PathBuf};
101use std::time::SystemTime;
102
103use serde::Serialize;
104use serde_json::Value;
105
106use crate::error::{Error, Result};
107
108#[derive(Debug, Clone)]
112pub struct HistoryRoot {
113 path: PathBuf,
114}
115
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub enum ListSort {
120 #[default]
126 NameAsc,
127 RecencyDesc,
134}
135
136#[derive(Debug, Clone)]
144pub struct ListOptions {
145 pub limit: Option<usize>,
147 pub offset: usize,
150 pub include_empty: bool,
160 pub sort: ListSort,
162}
163
164impl Default for ListOptions {
165 fn default() -> Self {
166 Self {
167 limit: None,
168 offset: 0,
169 include_empty: true,
170 sort: ListSort::default(),
171 }
172 }
173}
174
175impl HistoryRoot {
176 pub fn home() -> Result<Self> {
179 let home = home_dir().ok_or_else(|| Error::History {
180 message: "could not determine user home directory".to_string(),
181 })?;
182 Ok(Self {
183 path: home.join(".claude").join("projects"),
184 })
185 }
186
187 pub fn at(path: impl Into<PathBuf>) -> Self {
190 Self { path: path.into() }
191 }
192
193 pub fn path(&self) -> &Path {
195 &self.path
196 }
197
198 pub fn list_projects(&self) -> Result<Vec<ProjectSummary>> {
208 self.list_projects_with(&ListOptions::default())
209 }
210
211 pub fn list_projects_with(&self, opts: &ListOptions) -> Result<Vec<ProjectSummary>> {
225 let entries = match fs::read_dir(&self.path) {
226 Ok(it) => it,
227 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
228 Err(e) => return Err(e.into()),
229 };
230
231 let mut out = Vec::new();
232 for entry in entries.flatten() {
233 let ft = match entry.file_type() {
234 Ok(ft) => ft,
235 Err(_) => continue,
236 };
237 if !ft.is_dir() {
238 continue;
239 }
240 let slug = entry.file_name().to_string_lossy().into_owned();
241 let summary = summarize_project(&entry.path(), slug);
242 if !opts.include_empty && summary.session_count == 0 {
243 continue;
244 }
245 out.push(summary);
246 }
247 match opts.sort {
248 ListSort::NameAsc => out.sort_by(|a, b| a.slug.cmp(&b.slug)),
249 ListSort::RecencyDesc => out.sort_by(|a, b| {
250 match (a.last_modified, b.last_modified) {
252 (Some(am), Some(bm)) => bm.cmp(&am),
253 (Some(_), None) => std::cmp::Ordering::Less,
254 (None, Some(_)) => std::cmp::Ordering::Greater,
255 (None, None) => a.slug.cmp(&b.slug),
256 }
257 }),
258 }
259 apply_offset_limit(&mut out, opts);
260 Ok(out)
261 }
262
263 pub fn list_sessions(&self, slug: Option<&str>) -> Result<Vec<SessionSummary>> {
269 self.list_sessions_with(slug, &ListOptions::default())
270 }
271
272 pub fn list_sessions_with(
280 &self,
281 slug: Option<&str>,
282 opts: &ListOptions,
283 ) -> Result<Vec<SessionSummary>> {
284 let enumerate_opts = ListOptions {
287 include_empty: true,
288 ..ListOptions::default()
289 };
290 let project_dirs = match slug {
291 Some(s) => vec![self.path.join(s)],
292 None => self
293 .list_projects_with(&enumerate_opts)?
294 .into_iter()
295 .map(|p| self.path.join(&p.slug))
296 .collect(),
297 };
298
299 let mut out = Vec::new();
300 for dir in project_dirs {
301 let project_slug = dir
302 .file_name()
303 .map(|n| n.to_string_lossy().into_owned())
304 .unwrap_or_default();
305 let entries = match fs::read_dir(&dir) {
306 Ok(it) => it,
307 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
308 Err(e) => return Err(e.into()),
309 };
310 for entry in entries.flatten() {
311 let path = entry.path();
312 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
313 continue;
314 }
315 let Some(session_id) = path
316 .file_stem()
317 .and_then(|s| s.to_str())
318 .map(str::to_string)
319 else {
320 continue;
321 };
322 if let Some(summary) = summarize_session(&path, session_id, project_slug.clone()) {
323 if !opts.include_empty && summary.message_count == 0 {
324 continue;
325 }
326 out.push(summary);
327 }
328 }
329 }
330 match opts.sort {
331 ListSort::NameAsc => out.sort_by(|a, b| a.session_id.cmp(&b.session_id)),
332 ListSort::RecencyDesc => out.sort_by(|a, b| {
333 match (a.last_timestamp.as_deref(), b.last_timestamp.as_deref()) {
336 (Some(at), Some(bt)) => bt.cmp(at),
337 (Some(_), None) => std::cmp::Ordering::Less,
338 (None, Some(_)) => std::cmp::Ordering::Greater,
339 (None, None) => a.session_id.cmp(&b.session_id),
340 }
341 }),
342 }
343 apply_offset_limit(&mut out, opts);
344 Ok(out)
345 }
346
347 #[must_use]
363 pub fn project_slug(path: impl AsRef<Path>) -> String {
364 let path = path.as_ref();
365 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
366 encode_path_slug(&canonical.to_string_lossy())
367 }
368
369 pub fn sessions_for_path(&self, cwd: impl AsRef<Path>) -> Result<Vec<SessionSummary>> {
378 self.sessions_for_path_with(cwd, &ListOptions::default())
379 }
380
381 pub fn sessions_for_path_with(
383 &self,
384 cwd: impl AsRef<Path>,
385 opts: &ListOptions,
386 ) -> Result<Vec<SessionSummary>> {
387 let slug = Self::project_slug(cwd);
388 self.list_sessions_with(Some(&slug), opts)
389 }
390
391 pub fn read_session(&self, session_id: &str) -> Result<SessionLog> {
397 let (path, project_slug) =
398 self.find_session(session_id)?
399 .ok_or_else(|| Error::History {
400 message: format!(
401 "no session with id `{session_id}` under {}",
402 self.path.display()
403 ),
404 })?;
405 parse_session(&path, session_id.to_string(), project_slug)
406 }
407
408 pub fn find_session(&self, session_id: &str) -> Result<Option<(PathBuf, String)>> {
414 for project in self.list_projects()? {
415 let candidate = self
416 .path
417 .join(&project.slug)
418 .join(format!("{session_id}.jsonl"));
419 if candidate.is_file() {
420 return Ok(Some((candidate, project.slug)));
421 }
422 }
423 Ok(None)
424 }
425
426 pub fn list_subagents(&self, session_id: &str) -> Result<Vec<SubagentSummary>> {
433 let Some(dir) = self.session_dir(session_id)? else {
434 return Ok(Vec::new());
435 };
436 let mut out = Vec::new();
437 for path in list_files_with_extension(&dir.join("subagents"), "jsonl") {
438 let stem = match path.file_stem().and_then(|s| s.to_str()) {
440 Some(s) => s,
441 None => continue,
442 };
443 let agent_id = stem.strip_prefix("agent-").unwrap_or(stem).to_string();
444 let meta = read_subagent_meta(&path);
445 out.push(SubagentSummary {
446 agent_id,
447 path,
448 meta,
449 });
450 }
451 out.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
452 Ok(out)
453 }
454
455 pub fn read_subagent(&self, session_id: &str, agent_id: &str) -> Result<Vec<HistoryEntry>> {
464 let found = self
465 .list_subagents(session_id)?
466 .into_iter()
467 .find(|s| s.agent_id == agent_id)
468 .ok_or_else(|| Error::History {
469 message: format!("no subagent with id `{agent_id}` for session `{session_id}`"),
470 })?;
471 parse_jsonl_entries(&found.path)
472 }
473
474 pub fn list_tool_results(&self, session_id: &str) -> Result<Vec<ToolResultSummary>> {
482 let Some(dir) = self.session_dir(session_id)? else {
483 return Ok(Vec::new());
484 };
485 let mut out = Vec::new();
486 for path in list_files_with_extension(&dir.join("tool-results"), "txt") {
487 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
488 continue;
489 };
490 let size_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
491 out.push(ToolResultSummary {
492 tool_use_id: stem.to_string(),
493 path,
494 size_bytes,
495 });
496 }
497 out.sort_by(|a, b| a.tool_use_id.cmp(&b.tool_use_id));
498 Ok(out)
499 }
500
501 pub fn read_tool_result(&self, session_id: &str, tool_use_id: &str) -> Result<String> {
506 let found = self
507 .list_tool_results(session_id)?
508 .into_iter()
509 .find(|t| t.tool_use_id == tool_use_id)
510 .ok_or_else(|| Error::History {
511 message: format!(
512 "no tool result with id `{tool_use_id}` for session `{session_id}`"
513 ),
514 })?;
515 Ok(fs::read_to_string(&found.path)?)
516 }
517
518 pub fn list_workflows(&self, session_id: &str) -> Result<Vec<WorkflowSummary>> {
527 let Some(dir) = self.session_dir(session_id)? else {
528 return Ok(Vec::new());
529 };
530 let workflows_dir = dir.join("workflows");
531 let scripts: Vec<PathBuf> = list_files_with_extension(&workflows_dir.join("scripts"), "js");
532 let mut out = Vec::new();
533 for path in list_files_with_extension(&workflows_dir, "json") {
534 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
535 continue;
536 };
537 let workflow_id = stem.to_string();
538 let script_suffix = format!("-{workflow_id}.js");
539 let script_path = scripts
540 .iter()
541 .find(|p| {
542 p.file_name()
543 .and_then(|n| n.to_str())
544 .is_some_and(|n| n.ends_with(&script_suffix))
545 })
546 .cloned();
547 let size_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
548 out.push(WorkflowSummary {
549 workflow_id,
550 path,
551 script_path,
552 size_bytes,
553 });
554 }
555 out.sort_by(|a, b| a.workflow_id.cmp(&b.workflow_id));
556 Ok(out)
557 }
558
559 pub fn read_workflow(&self, session_id: &str, workflow_id: &str) -> Result<Value> {
566 let found = self
567 .list_workflows(session_id)?
568 .into_iter()
569 .find(|w| w.workflow_id == workflow_id)
570 .ok_or_else(|| Error::History {
571 message: format!("no workflow with id `{workflow_id}` for session `{session_id}`"),
572 })?;
573 let content = fs::read_to_string(&found.path)?;
574 serde_json::from_str(&content).map_err(|e| Error::History {
575 message: format!(
576 "workflow journal `{}` is not valid JSON: {e}",
577 found.path.display()
578 ),
579 })
580 }
581
582 pub fn prompt_history(&self) -> Result<Vec<PromptHistoryEntry>> {
594 self.prompt_history_with(&ListOptions::default())
595 }
596
597 pub fn prompt_history_with(&self, opts: &ListOptions) -> Result<Vec<PromptHistoryEntry>> {
601 let Some(parent) = self.path.parent() else {
602 return Ok(Vec::new());
603 };
604 let file_path = parent.join("history.jsonl");
605 if !file_path.is_file() {
606 return Ok(Vec::new());
607 }
608 let file = fs::File::open(&file_path)?;
609 let reader = BufReader::new(file);
610 let mut entries = Vec::new();
611 for (lineno, line) in reader.lines().enumerate() {
612 let line = match line {
613 Ok(l) => l,
614 Err(e) => {
615 tracing::warn!(
616 path = %file_path.display(),
617 line = lineno + 1,
618 error = %e,
619 "history: skipping unreadable prompt-history line",
620 );
621 continue;
622 }
623 };
624 let trimmed = line.trim();
625 if trimmed.is_empty() {
626 continue;
627 }
628 match parse_prompt_history_line(trimmed) {
629 Ok(entry) => entries.push(entry),
630 Err(e) => {
631 tracing::warn!(
632 path = %file_path.display(),
633 line = lineno + 1,
634 error = %e,
635 "history: skipping malformed prompt-history line",
636 );
637 }
638 }
639 }
640 apply_offset_limit(&mut entries, opts);
641 Ok(entries)
642 }
643
644 fn session_dir(&self, session_id: &str) -> Result<Option<PathBuf>> {
650 let (jsonl_path, _slug) = self
651 .find_session(session_id)?
652 .ok_or_else(|| Error::History {
653 message: format!(
654 "no session with id `{session_id}` under {}",
655 self.path.display()
656 ),
657 })?;
658 let dir = match jsonl_path.parent() {
659 Some(parent) => parent.join(session_id),
660 None => return Ok(None),
661 };
662 Ok(dir.is_dir().then_some(dir))
663 }
664}
665
666#[derive(Debug, Clone, Serialize)]
668pub struct ProjectSummary {
669 pub slug: String,
671 pub decoded_path: PathBuf,
678 pub is_decode_verified: bool,
696 pub session_count: usize,
698 pub last_modified: Option<SystemTime>,
701}
702
703#[derive(Debug, Clone, Serialize)]
705pub struct SessionSummary {
706 pub session_id: String,
708 pub project_slug: String,
710 pub message_count: usize,
713 pub first_timestamp: Option<String>,
716 pub last_timestamp: Option<String>,
718 pub title: Option<String>,
721 pub first_user_preview: Option<String>,
727 pub total_cost_usd: Option<f64>,
732 pub total_tokens: Option<u64>,
736 pub size_bytes: u64,
738}
739
740#[derive(Debug, Clone, Serialize)]
742pub struct SessionLog {
743 pub session_id: String,
745 pub project_slug: String,
747 pub entries: Vec<HistoryEntry>,
749}
750
751#[derive(Debug, Clone, Serialize)]
754pub struct SubagentSummary {
755 pub agent_id: String,
758 pub path: PathBuf,
760 pub meta: Option<SubagentMeta>,
762}
763
764#[derive(Debug, Clone, Serialize)]
766pub struct SubagentMeta {
767 pub agent_type: Option<String>,
769 pub description: Option<String>,
771 pub tool_use_id: Option<String>,
773 pub spawn_depth: Option<u64>,
775 #[serde(flatten)]
777 pub rest: serde_json::Map<String, Value>,
778}
779
780#[derive(Debug, Clone, Serialize)]
782pub struct ToolResultSummary {
783 pub tool_use_id: String,
786 pub path: PathBuf,
788 pub size_bytes: u64,
790}
791
792#[derive(Debug, Clone, Serialize)]
794pub struct WorkflowSummary {
795 pub workflow_id: String,
798 pub path: PathBuf,
800 pub script_path: Option<PathBuf>,
803 pub size_bytes: u64,
805}
806
807#[derive(Debug, Clone, Serialize)]
811pub struct PromptHistoryEntry {
812 pub display: Option<String>,
814 pub timestamp_ms: Option<u64>,
818 pub project: Option<String>,
820 pub session_id: Option<String>,
822 #[serde(flatten)]
825 pub rest: serde_json::Map<String, Value>,
826}
827
828#[derive(Debug, Clone, Serialize)]
835#[serde(tag = "kind", rename_all = "snake_case")]
836pub enum HistoryEntry {
837 User {
839 uuid: Option<String>,
841 timestamp: Option<String>,
843 cwd: Option<String>,
845 git_branch: Option<String>,
847 message: Value,
849 #[serde(flatten)]
854 rest: serde_json::Map<String, Value>,
855 },
856 Assistant {
858 uuid: Option<String>,
860 timestamp: Option<String>,
862 message: Value,
864 #[serde(flatten)]
869 rest: serde_json::Map<String, Value>,
870 },
871 Other {
873 type_tag: String,
875 raw: Value,
877 },
878}
879
880impl HistoryEntry {
881 pub fn from_line(line: &str) -> std::result::Result<Self, serde_json::Error> {
910 parse_entry(line)
911 }
912
913 pub fn field(&self, key: &str) -> Option<&Value> {
927 match self {
928 Self::User { rest, .. } | Self::Assistant { rest, .. } => rest.get(key),
929 Self::Other { raw, .. } => raw.get(key),
930 }
931 }
932
933 pub fn prompt_source(&self) -> Option<&str> {
938 self.field("promptSource").and_then(Value::as_str)
939 }
940
941 pub fn entrypoint(&self) -> Option<&str> {
944 self.field("entrypoint").and_then(Value::as_str)
945 }
946
947 pub fn is_meta(&self) -> Option<bool> {
950 self.field("isMeta").and_then(Value::as_bool)
951 }
952
953 pub fn is_sidechain(&self) -> Option<bool> {
956 self.field("isSidechain").and_then(Value::as_bool)
957 }
958
959 pub fn session_id(&self) -> Option<&str> {
961 self.field("sessionId").and_then(Value::as_str)
962 }
963
964 pub fn parent_uuid(&self) -> Option<&str> {
967 self.field("parentUuid").and_then(Value::as_str)
968 }
969}
970
971fn apply_offset_limit<T>(items: &mut Vec<T>, opts: &ListOptions) {
976 if opts.offset >= items.len() {
977 items.clear();
978 return;
979 }
980 if opts.offset > 0 {
981 items.drain(..opts.offset);
982 }
983 if let Some(lim) = opts.limit
984 && items.len() > lim
985 {
986 items.truncate(lim);
987 }
988}
989
990fn summarize_project(dir: &Path, slug: String) -> ProjectSummary {
991 let mut session_count = 0usize;
992 let mut last_modified: Option<SystemTime> = None;
993 if let Ok(entries) = fs::read_dir(dir) {
994 for entry in entries.flatten() {
995 let path = entry.path();
996 if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
997 session_count += 1;
998 if let Ok(meta) = entry.metadata()
999 && let Ok(mtime) = meta.modified()
1000 {
1001 last_modified = Some(match last_modified {
1002 Some(prev) if prev > mtime => prev,
1003 _ => mtime,
1004 });
1005 }
1006 }
1007 }
1008 }
1009 let (decoded_path, is_decode_verified) = decode_slug_anchored(&slug);
1010 ProjectSummary {
1011 decoded_path,
1012 is_decode_verified,
1013 slug,
1014 session_count,
1015 last_modified,
1016 }
1017}
1018
1019fn summarize_session(
1020 path: &Path,
1021 session_id: String,
1022 project_slug: String,
1023) -> Option<SessionSummary> {
1024 let meta = fs::metadata(path).ok()?;
1025 let size_bytes = meta.len();
1026
1027 let file = fs::File::open(path).ok()?;
1028 let reader = BufReader::new(file);
1029
1030 let mut message_count = 0usize;
1031 let mut first_timestamp = None;
1032 let mut last_timestamp = None;
1033 let mut title = None;
1034 let mut first_user_preview: Option<String> = None;
1035 let mut total_cost_usd: Option<f64> = None;
1036 let mut total_tokens: Option<u64> = None;
1037
1038 for line in reader.lines().map_while(std::io::Result::ok) {
1039 let trimmed = line.trim();
1040 if trimmed.is_empty() {
1041 continue;
1042 }
1043 let v: Value = match serde_json::from_str(trimmed) {
1044 Ok(v) => v,
1045 Err(_) => continue,
1046 };
1047 let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
1048 match ty {
1049 "user" => {
1050 message_count += 1;
1051 if first_user_preview.is_none()
1052 && let Some(p) = extract_user_text_preview(&v, 160)
1053 {
1054 first_user_preview = Some(p);
1055 }
1056 }
1057 "assistant" => {
1058 message_count += 1;
1059 if let Some(c) = v
1060 .get("message")
1061 .and_then(|m| m.get("usage"))
1062 .and_then(|u| u.get("total_cost_usd"))
1063 .and_then(Value::as_f64)
1064 {
1065 *total_cost_usd.get_or_insert(0.0) += c;
1066 }
1067 if let Some(usage) = v.get("message").and_then(|m| m.get("usage")) {
1068 let mut t = 0u64;
1070 for k in [
1071 "input_tokens",
1072 "output_tokens",
1073 "cache_creation_input_tokens",
1074 "cache_read_input_tokens",
1075 ] {
1076 if let Some(n) = usage.get(k).and_then(Value::as_u64) {
1077 t += n;
1078 }
1079 }
1080 if t > 0 {
1081 *total_tokens.get_or_insert(0) += t;
1082 }
1083 }
1084 }
1085 "ai-title" => {
1086 let candidate = v
1090 .get("aiTitle")
1091 .and_then(Value::as_str)
1092 .or_else(|| v.get("title").and_then(Value::as_str));
1093 if let Some(t) = candidate
1094 && !t.is_empty()
1095 {
1096 title = Some(t.to_string());
1097 }
1098 }
1099 _ => {}
1100 }
1101 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
1102 if first_timestamp.is_none() {
1103 first_timestamp = Some(ts.to_string());
1104 }
1105 last_timestamp = Some(ts.to_string());
1106 }
1107 }
1108
1109 Some(SessionSummary {
1110 session_id,
1111 project_slug,
1112 message_count,
1113 first_timestamp,
1114 last_timestamp,
1115 title,
1116 first_user_preview,
1117 total_cost_usd,
1118 total_tokens,
1119 size_bytes,
1120 })
1121}
1122
1123fn extract_user_text_preview(entry: &Value, max_chars: usize) -> Option<String> {
1129 let content = entry.get("message")?.get("content")?;
1130 let raw = if let Some(s) = content.as_str() {
1131 s.to_string()
1132 } else {
1133 let arr = content.as_array()?;
1134 let mut buf = String::new();
1135 for block in arr {
1136 let ty = block.get("type").and_then(Value::as_str).unwrap_or("");
1137 if ty == "text"
1138 && let Some(t) = block.get("text").and_then(Value::as_str)
1139 {
1140 if !buf.is_empty() {
1141 buf.push(' ');
1142 }
1143 buf.push_str(t);
1144 }
1145 }
1146 buf
1147 };
1148 let one_line = raw
1149 .split('\n')
1150 .map(str::trim)
1151 .filter(|l| !l.is_empty())
1152 .collect::<Vec<_>>()
1153 .join(" ");
1154 if one_line.is_empty() {
1155 return None;
1156 }
1157 let truncated: String = one_line.chars().take(max_chars).collect();
1158 if truncated.len() < one_line.len() {
1159 Some(format!("{truncated}..."))
1160 } else {
1161 Some(truncated)
1162 }
1163}
1164
1165fn parse_session(path: &Path, session_id: String, project_slug: String) -> Result<SessionLog> {
1166 let entries = parse_jsonl_entries(path)?;
1167 Ok(SessionLog {
1168 session_id,
1169 project_slug,
1170 entries,
1171 })
1172}
1173
1174fn parse_jsonl_entries(path: &Path) -> Result<Vec<HistoryEntry>> {
1177 let file = fs::File::open(path)?;
1178 let reader = BufReader::new(file);
1179
1180 let mut entries = Vec::new();
1181 for (lineno, line) in reader.lines().enumerate() {
1182 let line = match line {
1183 Ok(l) => l,
1184 Err(e) => {
1185 tracing::warn!(
1186 path = %path.display(),
1187 line = lineno + 1,
1188 error = %e,
1189 "history: skipping unreadable line",
1190 );
1191 continue;
1192 }
1193 };
1194 let trimmed = line.trim();
1195 if trimmed.is_empty() {
1196 continue;
1197 }
1198 match parse_entry(trimmed) {
1199 Ok(entry) => entries.push(entry),
1200 Err(e) => {
1201 tracing::warn!(
1202 path = %path.display(),
1203 line = lineno + 1,
1204 error = %e,
1205 "history: skipping malformed line",
1206 );
1207 }
1208 }
1209 }
1210 Ok(entries)
1211}
1212
1213fn list_files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
1216 let mut out = Vec::new();
1217 if let Ok(entries) = fs::read_dir(dir) {
1218 for entry in entries.flatten() {
1219 let path = entry.path();
1220 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext) {
1221 out.push(path);
1222 }
1223 }
1224 }
1225 out
1226}
1227
1228fn parse_prompt_history_line(
1229 line: &str,
1230) -> std::result::Result<PromptHistoryEntry, serde_json::Error> {
1231 let value: Value = serde_json::from_str(line)?;
1232 let mut rest = into_map(value);
1233 let timestamp_ms = match rest.remove("timestamp") {
1234 Some(v) => {
1235 let n = v.as_u64();
1236 if n.is_none() {
1237 rest.insert("timestamp".to_string(), v);
1238 }
1239 n
1240 }
1241 None => None,
1242 };
1243 Ok(PromptHistoryEntry {
1244 display: take_string(&mut rest, "display"),
1245 timestamp_ms,
1246 project: take_string(&mut rest, "project"),
1247 session_id: take_string(&mut rest, "sessionId"),
1248 rest,
1249 })
1250}
1251
1252fn read_subagent_meta(transcript_path: &Path) -> Option<SubagentMeta> {
1256 let meta_path = transcript_path.with_extension("meta.json");
1257 let content = fs::read_to_string(&meta_path).ok()?;
1258 let value: Value = serde_json::from_str(&content).ok()?;
1259 let mut rest = into_map(value);
1260 let spawn_depth = match rest.remove("spawnDepth") {
1261 Some(v) => {
1262 let n = v.as_u64();
1263 if n.is_none() {
1264 rest.insert("spawnDepth".to_string(), v);
1265 }
1266 n
1267 }
1268 None => None,
1269 };
1270 Some(SubagentMeta {
1271 agent_type: take_string(&mut rest, "agentType"),
1272 description: take_string(&mut rest, "description"),
1273 tool_use_id: take_string(&mut rest, "toolUseId"),
1274 spawn_depth,
1275 rest,
1276 })
1277}
1278
1279fn parse_entry(line: &str) -> std::result::Result<HistoryEntry, serde_json::Error> {
1280 let value: Value = serde_json::from_str(line)?;
1281 let ty = value
1282 .get("type")
1283 .and_then(Value::as_str)
1284 .unwrap_or("")
1285 .to_string();
1286 match ty.as_str() {
1287 "user" => {
1288 let mut rest = into_map(value);
1289 rest.remove("type");
1290 Ok(HistoryEntry::User {
1291 uuid: take_string(&mut rest, "uuid"),
1292 timestamp: take_string(&mut rest, "timestamp"),
1293 cwd: take_string(&mut rest, "cwd"),
1294 git_branch: take_string(&mut rest, "gitBranch"),
1295 message: rest.remove("message").unwrap_or(Value::Null),
1296 rest,
1297 })
1298 }
1299 "assistant" => {
1300 let mut rest = into_map(value);
1301 rest.remove("type");
1302 Ok(HistoryEntry::Assistant {
1303 uuid: take_string(&mut rest, "uuid"),
1304 timestamp: take_string(&mut rest, "timestamp"),
1305 message: rest.remove("message").unwrap_or(Value::Null),
1306 rest,
1307 })
1308 }
1309 other => Ok(HistoryEntry::Other {
1310 type_tag: other.to_string(),
1311 raw: value,
1312 }),
1313 }
1314}
1315
1316fn into_map(value: Value) -> serde_json::Map<String, Value> {
1320 match value {
1321 Value::Object(map) => map,
1322 _ => serde_json::Map::new(),
1323 }
1324}
1325
1326fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
1330 match map.remove(key) {
1331 Some(Value::String(s)) => Some(s),
1332 Some(other) => {
1333 map.insert(key.to_string(), other);
1334 None
1335 }
1336 None => None,
1337 }
1338}
1339
1340fn decode_slug_anchored(slug: &str) -> (PathBuf, bool) {
1372 let body = slug.strip_prefix('-').unwrap_or(slug);
1373 if body.is_empty() {
1376 return (PathBuf::from("/"), true);
1377 }
1378 let fragments: Vec<&str> = body.split('-').collect();
1379 let mut built_path = PathBuf::from("/");
1380 let mut is_decode_verified = true;
1381
1382 let mut i = 0;
1383 while i < fragments.len() {
1384 match longest_entry_match(&built_path, &fragments[i..]) {
1385 Some((name, consumed)) => {
1386 built_path.push(name);
1387 i += consumed;
1388 }
1389 None => {
1390 is_decode_verified = false;
1391 built_path.push(fragments[i]);
1392 i += 1;
1393 }
1394 }
1395 }
1396 (built_path, is_decode_verified)
1397}
1398
1399fn longest_entry_match(parent: &Path, fragments: &[&str]) -> Option<(String, usize)> {
1406 let entries = fs::read_dir(parent).ok()?;
1407 let mut best: Option<(String, usize)> = None;
1408 for entry in entries.flatten() {
1409 let Ok(name) = entry.file_name().into_string() else {
1411 continue;
1412 };
1413 let Some(consumed) = encoded_fragment_span(&name, fragments) else {
1414 continue;
1415 };
1416 let replace = match &best {
1417 None => true,
1418 Some((_, best_consumed)) if consumed > *best_consumed => true,
1419 Some((_, best_consumed)) if consumed < *best_consumed => false,
1420 Some((best_name, _)) => {
1421 let literal = fragments[..consumed].join("-");
1422 *best_name != literal && (name == literal || name < *best_name)
1423 }
1424 };
1425 if replace {
1426 best = Some((name, consumed));
1427 }
1428 }
1429 best
1430}
1431
1432fn encoded_fragment_span(name: &str, fragments: &[&str]) -> Option<usize> {
1437 let encoded = encode_path_slug(name);
1438 let mut joined = String::with_capacity(encoded.len());
1439 for (idx, fragment) in fragments.iter().enumerate() {
1440 if idx > 0 {
1441 joined.push('-');
1442 }
1443 joined.push_str(fragment);
1444 if joined.len() == encoded.len() {
1445 return (joined == encoded).then_some(idx + 1);
1446 }
1447 if joined.len() > encoded.len() {
1448 return None;
1449 }
1450 }
1451 None
1452}
1453
1454fn encode_path_slug(path: &str) -> String {
1464 path.chars()
1465 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
1466 .collect()
1467}
1468
1469fn home_dir() -> Option<PathBuf> {
1470 if let Ok(h) = std::env::var("HOME")
1473 && !h.is_empty()
1474 {
1475 return Some(PathBuf::from(h));
1476 }
1477 if let Ok(h) = std::env::var("USERPROFILE")
1478 && !h.is_empty()
1479 {
1480 return Some(PathBuf::from(h));
1481 }
1482 None
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487 use super::*;
1488 use std::io::Write;
1489
1490 fn write_session(dir: &Path, session_id: &str, lines: &[&str]) -> PathBuf {
1491 let path = dir.join(format!("{session_id}.jsonl"));
1492 let mut f = fs::File::create(&path).expect("create jsonl");
1493 for line in lines {
1494 writeln!(f, "{line}").unwrap();
1495 }
1496 path
1497 }
1498
1499 fn set_mtime(path: &Path, secs_since_epoch: u64) {
1504 let f = fs::OpenOptions::new()
1505 .write(true)
1506 .open(path)
1507 .expect("reopen for mtime");
1508 let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs_since_epoch);
1509 f.set_modified(when).expect("set mtime");
1510 }
1511
1512 fn fixture_root() -> tempfile::TempDir {
1513 let tmp = tempfile::tempdir().expect("tempdir");
1514 let a = tmp.path().join("-Users-josh-Code-projA");
1516 fs::create_dir_all(&a).unwrap();
1517 write_session(
1518 &a,
1519 "session-aaa",
1520 &[
1521 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/Users/josh/Code/projA","gitBranch":"main","message":{"role":"user","content":"hello"}}"#,
1522 r#"{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"hi"}}"#,
1523 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:02Z"}"#,
1524 r#"{"type":"ai-title","aiTitle":"hello world"}"#,
1525 ],
1526 );
1527 write_session(
1528 &a,
1529 "session-bbb",
1530 &[
1531 r#"{"type":"user","uuid":"u2","timestamp":"2026-01-02T00:00:00Z","message":{"role":"user","content":"second"}}"#,
1532 ],
1533 );
1534 let sub = a.join("session-aaa");
1537 fs::create_dir_all(sub.join("subagents")).unwrap();
1538 write_session(
1539 &sub.join("subagents"),
1540 "agent-abc123",
1541 &[
1542 r#"{"type":"user","uuid":"su1","agentId":"abc123","isSidechain":true,"message":{"role":"user","content":"subtask"}}"#,
1543 r#"{"type":"assistant","uuid":"sa1","agentId":"abc123","isSidechain":true,"message":{"role":"assistant","content":"done"}}"#,
1544 ],
1545 );
1546 fs::write(
1547 sub.join("subagents").join("agent-abc123.meta.json"),
1548 r#"{"agentType":"general-purpose","description":"audit the crate","toolUseId":"toolu_spawn1","spawnDepth":1,"futureField":"kept"}"#,
1549 )
1550 .unwrap();
1551 fs::create_dir_all(sub.join("tool-results")).unwrap();
1552 fs::write(
1553 sub.join("tool-results").join("toolu_r1.txt"),
1554 "spilled tool output",
1555 )
1556 .unwrap();
1557 fs::create_dir_all(sub.join("workflows").join("scripts")).unwrap();
1558 fs::write(
1559 sub.join("workflows").join("wf_run1.json"),
1560 r#"{"runId":"wf_run1","script":"export const meta = {}"}"#,
1561 )
1562 .unwrap();
1563 fs::write(
1564 sub.join("workflows")
1565 .join("scripts")
1566 .join("my-task-wf_run1.js"),
1567 "export const meta = {}",
1568 )
1569 .unwrap();
1570 let b = tmp.path().join("-private-tmp-projB");
1572 fs::create_dir_all(&b).unwrap();
1573 write_session(
1574 &b,
1575 "session-ccc",
1576 &[
1577 r#"{"type":"user","uuid":"u3","timestamp":"2026-02-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
1578 r#"NOT VALID JSON"#,
1579 r#"{"type":"assistant","uuid":"a3","timestamp":"2026-02-01T00:00:01Z","message":{"role":"assistant","content":"y"}}"#,
1580 ],
1581 );
1582 tmp
1583 }
1584
1585 #[test]
1586 fn list_projects_returns_directories_sorted_by_slug() {
1587 let tmp = fixture_root();
1588 let root = HistoryRoot::at(tmp.path());
1589 let projects = root.list_projects().expect("list projects");
1590 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
1591 assert_eq!(slugs, ["-Users-josh-Code-projA", "-private-tmp-projB"]);
1592 }
1593
1594 #[test]
1595 fn list_projects_counts_sessions() {
1596 let tmp = fixture_root();
1597 let root = HistoryRoot::at(tmp.path());
1598 let projects = root.list_projects().expect("list");
1599 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
1600 let b = projects.iter().find(|p| p.slug.contains("projB")).unwrap();
1601 assert_eq!(a.session_count, 2);
1602 assert_eq!(b.session_count, 1);
1603 }
1604
1605 #[test]
1606 fn list_projects_decodes_slug_to_filesystem_path() {
1607 let tmp = fixture_root();
1608 let root = HistoryRoot::at(tmp.path());
1609 let projects = root.list_projects().expect("list");
1610 let a = projects.iter().find(|p| p.slug.contains("projA")).unwrap();
1611 assert_eq!(a.decoded_path, PathBuf::from("/Users/josh/Code/projA"));
1612 }
1613
1614 #[test]
1615 fn list_projects_returns_empty_when_root_missing() {
1616 let tmp = tempfile::tempdir().unwrap();
1617 let root = HistoryRoot::at(tmp.path().join("does-not-exist"));
1618 let projects = root.list_projects().expect("ok");
1619 assert!(projects.is_empty());
1620 }
1621
1622 #[test]
1623 fn list_sessions_filtered_by_slug() {
1624 let tmp = fixture_root();
1625 let root = HistoryRoot::at(tmp.path());
1626 let sessions = root
1627 .list_sessions(Some("-Users-josh-Code-projA"))
1628 .expect("list");
1629 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
1630 assert_eq!(ids, ["session-aaa", "session-bbb"]);
1631 assert!(
1632 sessions
1633 .iter()
1634 .all(|s| s.project_slug == "-Users-josh-Code-projA")
1635 );
1636 }
1637
1638 #[test]
1639 fn list_sessions_unfiltered_returns_union() {
1640 let tmp = fixture_root();
1641 let root = HistoryRoot::at(tmp.path());
1642 let sessions = root.list_sessions(None).expect("list");
1643 assert_eq!(sessions.len(), 3);
1644 }
1645
1646 #[test]
1647 fn session_summary_counts_only_user_and_assistant() {
1648 let tmp = fixture_root();
1649 let root = HistoryRoot::at(tmp.path());
1650 let sessions = root.list_sessions(Some("-Users-josh-Code-projA")).unwrap();
1651 let aaa = sessions
1652 .iter()
1653 .find(|s| s.session_id == "session-aaa")
1654 .unwrap();
1655 assert_eq!(aaa.message_count, 2);
1657 assert_eq!(aaa.title.as_deref(), Some("hello world"));
1658 assert_eq!(aaa.first_timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1659 }
1660
1661 #[test]
1662 fn read_session_returns_typed_entries_and_skips_malformed_lines() {
1663 let tmp = fixture_root();
1664 let root = HistoryRoot::at(tmp.path());
1665 let log = root.read_session("session-ccc").expect("read");
1666 assert_eq!(log.session_id, "session-ccc");
1667 assert_eq!(log.project_slug, "-private-tmp-projB");
1668 assert_eq!(log.entries.len(), 2);
1670 assert!(matches!(log.entries[0], HistoryEntry::User { .. }));
1671 assert!(matches!(log.entries[1], HistoryEntry::Assistant { .. }));
1672 }
1673
1674 #[test]
1675 fn read_session_user_entry_carries_metadata() {
1676 let tmp = fixture_root();
1677 let root = HistoryRoot::at(tmp.path());
1678 let log = root.read_session("session-aaa").expect("read");
1679 match &log.entries[0] {
1680 HistoryEntry::User {
1681 uuid,
1682 timestamp,
1683 cwd,
1684 git_branch,
1685 ..
1686 } => {
1687 assert_eq!(uuid.as_deref(), Some("u1"));
1688 assert_eq!(timestamp.as_deref(), Some("2026-01-01T00:00:00Z"));
1689 assert_eq!(cwd.as_deref(), Some("/Users/josh/Code/projA"));
1690 assert_eq!(git_branch.as_deref(), Some("main"));
1691 }
1692 other => panic!("expected User entry, got {other:?}"),
1693 }
1694 }
1695
1696 #[test]
1697 fn parse_entry_populates_rest_with_unmodeled_fields() {
1698 let line = r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"/w","gitBranch":"main","message":{"role":"user","content":"hi"},"promptSource":"typed","entrypoint":"cli","isSidechain":false,"sessionId":"s1","parentUuid":null,"permissionMode":"default","version":"2.1.0"}"#;
1699 let entry = parse_entry(line).expect("parse");
1700 match &entry {
1701 HistoryEntry::User { rest, .. } => {
1702 assert_eq!(rest["promptSource"], "typed");
1703 assert_eq!(rest["permissionMode"], "default");
1704 assert_eq!(rest["version"], "2.1.0");
1705 for consumed in ["type", "uuid", "timestamp", "cwd", "gitBranch", "message"] {
1707 assert!(!rest.contains_key(consumed), "{consumed} leaked into rest");
1708 }
1709 }
1710 other => panic!("expected User entry, got {other:?}"),
1711 }
1712 }
1713
1714 #[test]
1715 fn parse_entry_keeps_mistyped_field_in_rest() {
1716 let line = r#"{"type":"assistant","uuid":42,"message":{"role":"assistant","content":"y"}}"#;
1719 let entry = parse_entry(line).expect("parse");
1720 match &entry {
1721 HistoryEntry::Assistant { uuid, rest, .. } => {
1722 assert_eq!(uuid.as_deref(), None);
1723 assert_eq!(rest["uuid"], 42);
1724 }
1725 other => panic!("expected Assistant entry, got {other:?}"),
1726 }
1727 }
1728
1729 #[test]
1730 fn from_line_is_the_public_face_of_parse_entry() {
1731 let user = r#"{"type":"user","uuid":"u1","cwd":"/w","message":{"role":"user"}}"#;
1734 match HistoryEntry::from_line(user).expect("parse") {
1735 HistoryEntry::User { uuid, cwd, .. } => {
1736 assert_eq!(uuid.as_deref(), Some("u1"));
1737 assert_eq!(cwd.as_deref(), Some("/w"));
1738 }
1739 other => panic!("expected User entry, got {other:?}"),
1740 }
1741
1742 let unknown = r#"{"type":"ai-title","title":"t"}"#;
1743 match HistoryEntry::from_line(unknown).expect("parse") {
1744 HistoryEntry::Other { type_tag, raw } => {
1745 assert_eq!(type_tag, "ai-title");
1746 assert_eq!(raw["title"], "t");
1747 }
1748 other => panic!("expected Other entry, got {other:?}"),
1749 }
1750
1751 assert!(HistoryEntry::from_line("not json").is_err());
1752 }
1753
1754 #[test]
1755 fn typed_accessors_resolve_from_rest() {
1756 let line = r#"{"type":"user","uuid":"u1","message":{},"promptSource":"sdk","entrypoint":"sdk-cli","isSidechain":true,"isMeta":true,"sessionId":"s1","parentUuid":"p1"}"#;
1757 let entry = parse_entry(line).expect("parse");
1758 assert_eq!(entry.prompt_source(), Some("sdk"));
1759 assert_eq!(entry.entrypoint(), Some("sdk-cli"));
1760 assert_eq!(entry.is_sidechain(), Some(true));
1761 assert_eq!(entry.is_meta(), Some(true));
1762 assert_eq!(entry.session_id(), Some("s1"));
1763 assert_eq!(entry.parent_uuid(), Some("p1"));
1764 assert_eq!(
1765 entry.field("promptSource").and_then(Value::as_str),
1766 Some("sdk")
1767 );
1768 }
1769
1770 #[test]
1771 fn typed_accessors_return_none_when_fields_absent() {
1772 let line = r#"{"type":"assistant","uuid":"a1","message":{},"parentUuid":null}"#;
1773 let entry = parse_entry(line).expect("parse");
1774 assert_eq!(entry.prompt_source(), None);
1775 assert_eq!(entry.entrypoint(), None);
1776 assert_eq!(entry.is_meta(), None);
1777 assert_eq!(entry.is_sidechain(), None);
1778 assert_eq!(entry.session_id(), None);
1779 assert_eq!(entry.parent_uuid(), None);
1781 assert_eq!(entry.field("noSuchField"), None);
1782 }
1783
1784 #[test]
1785 fn field_and_accessors_resolve_on_other_variant() {
1786 let line = r#"{"type":"queue-operation","operation":"enqueue","sessionId":"s9"}"#;
1787 let entry = parse_entry(line).expect("parse");
1788 assert_eq!(
1789 entry.field("operation").and_then(Value::as_str),
1790 Some("enqueue")
1791 );
1792 assert_eq!(entry.session_id(), Some("s9"));
1793 assert_eq!(entry.prompt_source(), None);
1794 }
1795
1796 #[test]
1797 fn serialized_entry_reemits_rest_fields_at_top_level() {
1798 let line = r#"{"type":"user","uuid":"u1","message":{},"promptSource":"typed"}"#;
1799 let entry = parse_entry(line).expect("parse");
1800 let v = serde_json::to_value(&entry).expect("serialize");
1801 assert_eq!(v["kind"], "user");
1802 assert_eq!(v["promptSource"], "typed");
1803 }
1804
1805 fn prompt_history_fixture(lines: &[&str]) -> (tempfile::TempDir, HistoryRoot) {
1808 let tmp = tempfile::tempdir().expect("tempdir");
1809 let projects = tmp.path().join("projects");
1810 fs::create_dir_all(&projects).unwrap();
1811 let mut f = fs::File::create(tmp.path().join("history.jsonl")).unwrap();
1812 for line in lines {
1813 writeln!(f, "{line}").unwrap();
1814 }
1815 let root = HistoryRoot::at(projects);
1816 (tmp, root)
1817 }
1818
1819 #[test]
1820 fn prompt_history_parses_fields_and_keeps_rest() {
1821 let (_tmp, root) = prompt_history_fixture(&[
1822 r#"{"display":"fix the tests","pastedContents":{"1":"snippet"},"timestamp":1781804723955,"project":"/Users/me/Code/projA","sessionId":"s1","futureField":true}"#,
1823 ]);
1824 let entries = root.prompt_history().expect("read");
1825 assert_eq!(entries.len(), 1);
1826 let e = &entries[0];
1827 assert_eq!(e.display.as_deref(), Some("fix the tests"));
1828 assert_eq!(e.timestamp_ms, Some(1781804723955));
1829 assert_eq!(e.project.as_deref(), Some("/Users/me/Code/projA"));
1830 assert_eq!(e.session_id.as_deref(), Some("s1"));
1831 assert_eq!(e.rest["pastedContents"]["1"], "snippet");
1832 assert_eq!(e.rest["futureField"], true);
1833 for consumed in ["display", "timestamp", "project", "sessionId"] {
1834 assert!(
1835 !e.rest.contains_key(consumed),
1836 "{consumed} leaked into rest"
1837 );
1838 }
1839 }
1840
1841 #[test]
1842 fn prompt_history_keeps_mistyped_timestamp_in_rest() {
1843 let (_tmp, root) =
1844 prompt_history_fixture(&[r#"{"display":"x","timestamp":"not-a-number"}"#]);
1845 let entries = root.prompt_history().expect("read");
1846 assert_eq!(entries[0].timestamp_ms, None);
1847 assert_eq!(entries[0].rest["timestamp"], "not-a-number");
1848 }
1849
1850 #[test]
1851 fn prompt_history_skips_malformed_lines_and_paginates() {
1852 let (_tmp, root) = prompt_history_fixture(&[
1853 r#"{"display":"one","timestamp":1}"#,
1854 r#"NOT JSON"#,
1855 r#"{"display":"two","timestamp":2}"#,
1856 r#"{"display":"three","timestamp":3}"#,
1857 ]);
1858 let all = root.prompt_history().expect("read");
1859 assert_eq!(all.len(), 3);
1860 assert_eq!(all[0].display.as_deref(), Some("one"));
1862 let page = root
1863 .prompt_history_with(&ListOptions {
1864 offset: 1,
1865 limit: Some(1),
1866 ..ListOptions::default()
1867 })
1868 .expect("read");
1869 assert_eq!(page.len(), 1);
1870 assert_eq!(page[0].display.as_deref(), Some("two"));
1871 }
1872
1873 #[test]
1874 fn prompt_history_missing_file_is_empty() {
1875 let tmp = tempfile::tempdir().unwrap();
1876 let projects = tmp.path().join("projects");
1877 fs::create_dir_all(&projects).unwrap();
1878 let root = HistoryRoot::at(projects);
1880 assert!(root.prompt_history().expect("ok").is_empty());
1881 }
1882
1883 #[test]
1884 fn list_subagents_parses_ids_and_meta() {
1885 let tmp = fixture_root();
1886 let root = HistoryRoot::at(tmp.path());
1887 let subs = root.list_subagents("session-aaa").expect("list");
1888 assert_eq!(subs.len(), 1);
1889 let sub = &subs[0];
1890 assert_eq!(sub.agent_id, "abc123");
1891 let meta = sub.meta.as_ref().expect("meta parsed");
1892 assert_eq!(meta.agent_type.as_deref(), Some("general-purpose"));
1893 assert_eq!(meta.description.as_deref(), Some("audit the crate"));
1894 assert_eq!(meta.tool_use_id.as_deref(), Some("toolu_spawn1"));
1895 assert_eq!(meta.spawn_depth, Some(1));
1896 assert_eq!(meta.rest["futureField"], "kept");
1897 }
1898
1899 #[test]
1900 fn read_subagent_entries_carry_sidechain_attribution() {
1901 let tmp = fixture_root();
1902 let root = HistoryRoot::at(tmp.path());
1903 let entries = root.read_subagent("session-aaa", "abc123").expect("read");
1904 assert_eq!(entries.len(), 2);
1905 assert!(matches!(entries[0], HistoryEntry::User { .. }));
1906 assert_eq!(entries[0].is_sidechain(), Some(true));
1907 assert_eq!(
1908 entries[0].field("agentId").and_then(Value::as_str),
1909 Some("abc123")
1910 );
1911 }
1912
1913 #[test]
1914 fn read_subagent_unknown_agent_errors() {
1915 let tmp = fixture_root();
1916 let root = HistoryRoot::at(tmp.path());
1917 let err = root.read_subagent("session-aaa", "nope").unwrap_err();
1918 assert!(format!("{err}").contains("no subagent with id"));
1919 }
1920
1921 #[test]
1922 fn session_without_subdirectory_lists_empty() {
1923 let tmp = fixture_root();
1924 let root = HistoryRoot::at(tmp.path());
1925 assert!(root.list_subagents("session-bbb").expect("ok").is_empty());
1926 assert!(
1927 root.list_tool_results("session-bbb")
1928 .expect("ok")
1929 .is_empty()
1930 );
1931 assert!(root.list_workflows("session-bbb").expect("ok").is_empty());
1932 }
1933
1934 #[test]
1935 fn subdirectory_lookups_error_on_unknown_session() {
1936 let tmp = fixture_root();
1937 let root = HistoryRoot::at(tmp.path());
1938 let err = root.list_subagents("not-a-session").unwrap_err();
1939 assert!(matches!(err, Error::History { .. }));
1940 assert!(format!("{err}").contains("no session with id"));
1941 }
1942
1943 #[test]
1944 fn tool_result_list_and_read_round_trip() {
1945 let tmp = fixture_root();
1946 let root = HistoryRoot::at(tmp.path());
1947 let results = root.list_tool_results("session-aaa").expect("list");
1948 assert_eq!(results.len(), 1);
1949 assert_eq!(results[0].tool_use_id, "toolu_r1");
1950 assert_eq!(results[0].size_bytes, "spilled tool output".len() as u64);
1951 let content = root
1952 .read_tool_result("session-aaa", "toolu_r1")
1953 .expect("read");
1954 assert_eq!(content, "spilled tool output");
1955 let err = root
1956 .read_tool_result("session-aaa", "toolu_nope")
1957 .unwrap_err();
1958 assert!(format!("{err}").contains("no tool result with id"));
1959 }
1960
1961 #[test]
1962 fn workflow_list_links_script_and_read_parses_json() {
1963 let tmp = fixture_root();
1964 let root = HistoryRoot::at(tmp.path());
1965 let flows = root.list_workflows("session-aaa").expect("list");
1966 assert_eq!(flows.len(), 1);
1967 assert_eq!(flows[0].workflow_id, "wf_run1");
1968 let script = flows[0].script_path.as_ref().expect("script linked");
1969 assert!(script.ends_with("my-task-wf_run1.js"));
1970 let journal = root.read_workflow("session-aaa", "wf_run1").expect("read");
1971 assert_eq!(journal["runId"], "wf_run1");
1972 let err = root.read_workflow("session-aaa", "wf_nope").unwrap_err();
1973 assert!(format!("{err}").contains("no workflow with id"));
1974 }
1975
1976 #[test]
1977 fn read_session_other_entry_preserves_type_tag_and_raw() {
1978 let tmp = fixture_root();
1979 let root = HistoryRoot::at(tmp.path());
1980 let log = root.read_session("session-aaa").expect("read");
1981 let queue_op = log
1983 .entries
1984 .iter()
1985 .find(|e| matches!(e, HistoryEntry::Other { type_tag, .. } if type_tag == "queue-operation"))
1986 .expect("queue-operation entry");
1987 if let HistoryEntry::Other { raw, .. } = queue_op {
1988 assert_eq!(raw["operation"], "enqueue");
1989 }
1990 }
1991
1992 #[test]
1993 fn read_session_unknown_id_errors() {
1994 let tmp = fixture_root();
1995 let root = HistoryRoot::at(tmp.path());
1996 let err = root.read_session("not-a-real-session").unwrap_err();
1997 assert!(matches!(err, Error::History { .. }));
1998 assert!(format!("{err}").contains("no session with id"));
1999 }
2000
2001 #[test]
2002 fn find_session_returns_none_for_unknown_id() {
2003 let tmp = fixture_root();
2004 let root = HistoryRoot::at(tmp.path());
2005 let found = root.find_session("nope").expect("ok");
2006 assert!(found.is_none());
2007 }
2008
2009 #[test]
2010 fn find_session_locates_real_session() {
2011 let tmp = fixture_root();
2012 let root = HistoryRoot::at(tmp.path());
2013 let (path, slug) = root
2014 .find_session("session-ccc")
2015 .expect("ok")
2016 .expect("found");
2017 assert!(path.ends_with("session-ccc.jsonl"));
2018 assert_eq!(slug, "-private-tmp-projB");
2019 }
2020
2021 #[test]
2022 fn decode_slug_anchored_no_hyphens_in_components() {
2023 let (path, _verified) = decode_slug_anchored("-a-b-c-d");
2028 assert_eq!(path, PathBuf::from("/a/b/c/d"));
2029 }
2030
2031 #[cfg(unix)]
2037 fn slug_for(path: &Path) -> String {
2038 encode_path_slug(&path.to_string_lossy())
2039 }
2040
2041 #[cfg(unix)]
2042 #[test]
2043 fn decode_slug_anchored_single_hyphenated_segment() {
2044 let tmp = tempfile::tempdir().unwrap();
2046 let dir = tmp.path().join("foo-bar");
2047 fs::create_dir_all(&dir).unwrap();
2048 let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2049 assert_eq!(decoded, dir);
2050 assert!(is_verified);
2051 }
2052
2053 #[cfg(unix)]
2054 #[test]
2055 fn decode_slug_anchored_multiple_hyphenated_segments() {
2056 let tmp = tempfile::tempdir().unwrap();
2058 let dir = tmp.path().join("foo-bar").join("baz-qux");
2059 fs::create_dir_all(&dir).unwrap();
2060 let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2061 assert_eq!(decoded, dir);
2062 assert!(is_verified);
2063 }
2064
2065 #[cfg(unix)]
2066 #[test]
2067 fn decode_slug_anchored_dotted_component() {
2068 let tmp = tempfile::tempdir().unwrap();
2073 let dir = tmp
2074 .path()
2075 .join("github.com")
2076 .join("owner")
2077 .join("redismodule-rs");
2078 fs::create_dir_all(&dir).unwrap();
2079 let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2080 assert_eq!(decoded, dir);
2081 assert!(is_verified);
2082 }
2083
2084 #[cfg(unix)]
2085 #[test]
2086 fn decode_slug_anchored_underscore_and_space_components() {
2087 let tmp = tempfile::tempdir().unwrap();
2088 let dir = tmp.path().join("my_project").join("a b");
2089 fs::create_dir_all(&dir).unwrap();
2090 let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2091 assert_eq!(decoded, dir);
2092 assert!(is_verified);
2093 }
2094
2095 #[cfg(unix)]
2096 #[test]
2097 fn decode_slug_anchored_prefers_literal_hyphen_on_collision() {
2098 let tmp = tempfile::tempdir().unwrap();
2102 fs::create_dir_all(tmp.path().join("foo-bar")).unwrap();
2103 fs::create_dir_all(tmp.path().join("foo.bar")).unwrap();
2104 let expected = tmp.path().join("foo-bar");
2105 let (decoded, is_verified) = decode_slug_anchored(&slug_for(&expected));
2106 assert_eq!(decoded, expected);
2107 assert!(is_verified);
2108 }
2109
2110 #[cfg(unix)]
2111 #[test]
2112 fn decode_slug_anchored_deleted_leaf_is_unverified() {
2113 let tmp = tempfile::tempdir().unwrap();
2117 let gone = tmp.path().join("gone");
2118 let (decoded, is_verified) = decode_slug_anchored(&slug_for(&gone));
2119 assert_eq!(decoded, gone);
2120 assert!(!is_verified);
2121 }
2122
2123 #[cfg(unix)]
2124 #[test]
2125 fn decode_slug_anchored_miss_does_not_poison_the_remainder() {
2126 use std::os::unix::fs::PermissionsExt;
2132
2133 let tmp = tempfile::tempdir().unwrap();
2134 let locked = tmp.path().join("locked");
2135 let dir = locked.join("repo").join("claude-wrapper");
2136 fs::create_dir_all(&dir).unwrap();
2137 let slug = slug_for(&dir);
2138
2139 fs::set_permissions(&locked, fs::Permissions::from_mode(0o311)).unwrap();
2140 let result = decode_slug_anchored(&slug);
2141 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
2144
2145 let (decoded, is_verified) = result;
2146 assert_eq!(decoded, dir);
2147 assert!(!is_verified);
2148 }
2149
2150 #[test]
2151 fn decode_slug_anchored_root_slug_is_verified() {
2152 let (path, verified) = decode_slug_anchored("-");
2155 assert_eq!(path, PathBuf::from("/"));
2156 assert!(verified);
2157 }
2158
2159 #[test]
2160 fn decode_slug_anchored_fallback_when_nothing_exists() {
2161 let (path, verified) = decode_slug_anchored("-nonexistent-xyz-abc-def");
2163 assert_eq!(path, PathBuf::from("/nonexistent/xyz/abc/def"));
2164 assert!(!verified);
2165 }
2166
2167 #[cfg(unix)]
2168 #[test]
2169 fn decode_slug_anchored_real_world_issue_example() {
2170 let tmp = tempfile::tempdir().unwrap();
2175 let dir = tmp.path().join("rust").join("claude-wrapper");
2176 fs::create_dir_all(&dir).unwrap();
2177 let (decoded, is_verified) = decode_slug_anchored(&slug_for(&dir));
2178 assert_eq!(decoded, dir);
2179 assert!(is_verified);
2180 }
2181
2182 fn paginated_fixture() -> tempfile::TempDir {
2187 let tmp = tempfile::tempdir().unwrap();
2188 for stem in ["-zzz-empty1", "-aaa-empty2"] {
2190 fs::create_dir_all(tmp.path().join(stem)).unwrap();
2191 }
2192 for (stem, ts, mtime) in [
2193 ("-bbb-proj", "2026-03-01T00:00:00Z", 1_700_000_000),
2194 ("-ccc-proj", "2026-04-01T00:00:00Z", 1_700_001_000),
2195 ("-ddd-proj", "2026-05-01T00:00:00Z", 1_700_002_000),
2196 ] {
2197 let dir = tmp.path().join(stem);
2198 fs::create_dir_all(&dir).unwrap();
2199 let session_path = write_session(
2200 &dir,
2201 "s1",
2202 &[&format!(
2203 r#"{{"type":"user","uuid":"u","timestamp":"{ts}","message":{{"role":"user","content":"x"}}}}"#
2204 )],
2205 );
2206 set_mtime(&session_path, mtime);
2207 }
2208 tmp
2209 }
2210
2211 #[test]
2212 fn list_projects_with_include_empty_false_filters_them_out() {
2213 let tmp = paginated_fixture();
2214 let root = HistoryRoot::at(tmp.path());
2215 let projects = root
2216 .list_projects_with(&ListOptions {
2217 include_empty: false,
2218 ..Default::default()
2219 })
2220 .expect("list");
2221 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2222 assert_eq!(slugs, ["-bbb-proj", "-ccc-proj", "-ddd-proj"]);
2224 }
2225
2226 #[test]
2227 fn list_projects_with_default_includes_empty_for_bc() {
2228 let tmp = paginated_fixture();
2231 let root = HistoryRoot::at(tmp.path());
2232 let projects = root
2233 .list_projects_with(&ListOptions::default())
2234 .expect("list");
2235 assert_eq!(projects.len(), 5);
2236 }
2237
2238 #[test]
2239 fn list_projects_zero_arg_preserves_legacy_inclusion() {
2240 let tmp = paginated_fixture();
2243 let root = HistoryRoot::at(tmp.path());
2244 let projects = root.list_projects().expect("list");
2245 assert_eq!(projects.len(), 5);
2246 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2247 assert_eq!(
2248 slugs,
2249 [
2250 "-aaa-empty2",
2251 "-bbb-proj",
2252 "-ccc-proj",
2253 "-ddd-proj",
2254 "-zzz-empty1",
2255 ]
2256 );
2257 }
2258
2259 #[test]
2260 fn list_projects_with_limit_caps_results() {
2261 let tmp = paginated_fixture();
2262 let root = HistoryRoot::at(tmp.path());
2263 let projects = root
2264 .list_projects_with(&ListOptions {
2265 limit: Some(2),
2266 include_empty: true,
2267 ..Default::default()
2268 })
2269 .expect("list");
2270 assert_eq!(projects.len(), 2);
2271 }
2272
2273 #[test]
2274 fn list_projects_with_offset_skips() {
2275 let tmp = paginated_fixture();
2276 let root = HistoryRoot::at(tmp.path());
2277 let projects = root
2278 .list_projects_with(&ListOptions {
2279 offset: 3,
2280 include_empty: true,
2281 ..Default::default()
2282 })
2283 .expect("list");
2284 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2287 assert_eq!(slugs, ["-ddd-proj", "-zzz-empty1"]);
2288 }
2289
2290 #[test]
2291 fn list_projects_with_offset_past_end_returns_empty() {
2292 let tmp = paginated_fixture();
2293 let root = HistoryRoot::at(tmp.path());
2294 let projects = root
2295 .list_projects_with(&ListOptions {
2296 offset: 99,
2297 include_empty: true,
2298 ..Default::default()
2299 })
2300 .expect("list");
2301 assert!(projects.is_empty());
2302 }
2303
2304 #[test]
2305 fn list_projects_with_recency_desc_sort() {
2306 let tmp = paginated_fixture();
2307 let root = HistoryRoot::at(tmp.path());
2308 let projects = root
2312 .list_projects_with(&ListOptions {
2313 sort: ListSort::RecencyDesc,
2314 include_empty: false,
2315 ..Default::default()
2316 })
2317 .expect("list");
2318 let slugs: Vec<&str> = projects.iter().map(|p| p.slug.as_str()).collect();
2319 assert_eq!(slugs, ["-ddd-proj", "-ccc-proj", "-bbb-proj"]);
2320 }
2321
2322 #[test]
2323 fn list_sessions_with_include_empty_false_filters_zero_message() {
2324 let tmp = tempfile::tempdir().unwrap();
2325 let dir = tmp.path().join("-proj");
2326 fs::create_dir_all(&dir).unwrap();
2327 write_session(
2329 &dir,
2330 "real",
2331 &[
2332 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2333 ],
2334 );
2335 write_session(
2337 &dir,
2338 "orphan",
2339 &[
2340 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
2341 ],
2342 );
2343 let root = HistoryRoot::at(tmp.path());
2344 let sessions = root
2345 .list_sessions_with(
2346 Some("-proj"),
2347 &ListOptions {
2348 include_empty: false,
2349 ..Default::default()
2350 },
2351 )
2352 .expect("list");
2353 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2354 assert_eq!(ids, ["real"]);
2355 }
2356
2357 #[test]
2358 fn list_sessions_with_default_returns_orphans_for_bc() {
2359 let tmp = tempfile::tempdir().unwrap();
2360 let dir = tmp.path().join("-proj");
2361 fs::create_dir_all(&dir).unwrap();
2362 write_session(
2363 &dir,
2364 "orphan",
2365 &[
2366 r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-01T00:00:00Z"}"#,
2367 ],
2368 );
2369 let root = HistoryRoot::at(tmp.path());
2370 let sessions = root
2371 .list_sessions_with(Some("-proj"), &ListOptions::default())
2372 .expect("list");
2373 assert_eq!(sessions.len(), 1);
2374 assert_eq!(sessions[0].message_count, 0);
2375 }
2376
2377 #[test]
2378 fn list_sessions_with_recency_desc_sort() {
2379 let tmp = tempfile::tempdir().unwrap();
2380 let dir = tmp.path().join("-proj");
2381 fs::create_dir_all(&dir).unwrap();
2382 let old_p = write_session(
2383 &dir,
2384 "old",
2385 &[
2386 r#"{"type":"user","uuid":"u","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2387 ],
2388 );
2389 let new_p = write_session(
2390 &dir,
2391 "new",
2392 &[
2393 r#"{"type":"user","uuid":"u","timestamp":"2026-12-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2394 ],
2395 );
2396 let mid_p = write_session(
2397 &dir,
2398 "mid",
2399 &[
2400 r#"{"type":"user","uuid":"u","timestamp":"2026-06-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2401 ],
2402 );
2403 set_mtime(&old_p, 1_700_000_000);
2404 set_mtime(&mid_p, 1_700_001_000);
2405 set_mtime(&new_p, 1_700_002_000);
2406 let root = HistoryRoot::at(tmp.path());
2407 let sessions = root
2408 .list_sessions_with(
2409 Some("-proj"),
2410 &ListOptions {
2411 sort: ListSort::RecencyDesc,
2412 ..Default::default()
2413 },
2414 )
2415 .expect("list");
2416 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2417 assert_eq!(ids, ["new", "mid", "old"]);
2418 }
2419
2420 #[test]
2421 fn list_sessions_with_limit_and_offset_combine() {
2422 let tmp = tempfile::tempdir().unwrap();
2423 let dir = tmp.path().join("-proj");
2424 fs::create_dir_all(&dir).unwrap();
2425 for i in 0..5 {
2426 write_session(
2427 &dir,
2428 &format!("s{i}"),
2429 &[&format!(
2430 r#"{{"type":"user","uuid":"u","timestamp":"2026-01-0{i}T00:00:00Z","message":{{"role":"user","content":"x"}}}}"#
2431 )],
2432 );
2433 }
2434 let root = HistoryRoot::at(tmp.path());
2435 let sessions = root
2436 .list_sessions_with(
2437 Some("-proj"),
2438 &ListOptions {
2439 offset: 1,
2440 limit: Some(2),
2441 ..Default::default()
2442 },
2443 )
2444 .expect("list");
2445 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
2446 assert_eq!(ids, ["s1", "s2"]);
2448 }
2449
2450 #[test]
2453 fn session_summary_parses_ai_title_camelcase() {
2454 let tmp = tempfile::tempdir().unwrap();
2457 let dir = tmp.path().join("-proj");
2458 fs::create_dir_all(&dir).unwrap();
2459 write_session(
2460 &dir,
2461 "real-shape",
2462 &[
2463 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2464 r#"{"type":"ai-title","aiTitle":"My Session","sessionId":"real-shape"}"#,
2465 ],
2466 );
2467 let root = HistoryRoot::at(tmp.path());
2468 let sessions = root.list_sessions(Some("-proj")).expect("list");
2469 let s = sessions
2470 .iter()
2471 .find(|s| s.session_id == "real-shape")
2472 .unwrap();
2473 assert_eq!(s.title.as_deref(), Some("My Session"));
2474 }
2475
2476 #[test]
2477 fn session_summary_legacy_title_field_still_works() {
2478 let tmp = tempfile::tempdir().unwrap();
2480 let dir = tmp.path().join("-proj");
2481 fs::create_dir_all(&dir).unwrap();
2482 write_session(
2483 &dir,
2484 "legacy",
2485 &[
2486 r#"{"type":"user","uuid":"u","timestamp":"2026-05-01T00:00:00Z","message":{"role":"user","content":"x"}}"#,
2487 r#"{"type":"ai-title","title":"Legacy Form"}"#,
2488 ],
2489 );
2490 let root = HistoryRoot::at(tmp.path());
2491 let sessions = root.list_sessions(Some("-proj")).expect("list");
2492 let s = sessions.iter().find(|s| s.session_id == "legacy").unwrap();
2493 assert_eq!(s.title.as_deref(), Some("Legacy Form"));
2494 }
2495
2496 #[test]
2499 fn encode_path_slug_encodes_slash_and_dot() {
2500 assert_eq!(
2501 encode_path_slug("/Users/josh/Code/projA"),
2502 "-Users-josh-Code-projA"
2503 );
2504 assert_eq!(
2506 encode_path_slug("/private/var/folders/T/tmp.AbC"),
2507 "-private-var-folders-T-tmp-AbC"
2508 );
2509 assert_eq!(
2513 encode_path_slug("/Users/me/genagent/claude_wrapper_ex"),
2514 "-Users-me-genagent-claude-wrapper-ex"
2515 );
2516 assert_eq!(
2517 encode_path_slug("/Users/me/My Project (v2)"),
2518 "-Users-me-My-Project--v2-"
2519 );
2520 }
2521
2522 #[test]
2523 fn project_slug_canonicalizes_and_encodes_dot() {
2524 let work = tempfile::tempdir().unwrap();
2525 let cwd = work.path().join("my.proj");
2526 fs::create_dir_all(&cwd).unwrap();
2527
2528 let slug = HistoryRoot::project_slug(&cwd);
2529 assert!(
2530 slug.contains("my-proj"),
2531 "dotted segment must encode '.' -> '-', got {slug}"
2532 );
2533 assert!(
2534 !slug.contains('.'),
2535 "no '.' may survive in the slug: {slug}"
2536 );
2537 assert!(
2538 !slug.contains('/'),
2539 "no '/' may survive in the slug: {slug}"
2540 );
2541 }
2542
2543 #[test]
2544 fn project_slug_canonicalizes_and_encodes_underscore() {
2545 let work = tempfile::tempdir().unwrap();
2548 let cwd = work.path().join("claude_wrapper_ex");
2549 fs::create_dir_all(&cwd).unwrap();
2550
2551 let slug = HistoryRoot::project_slug(&cwd);
2552 assert!(
2553 slug.contains("claude-wrapper-ex"),
2554 "underscored segment must encode '_' -> '-', got {slug}"
2555 );
2556 assert!(
2557 !slug.contains('_'),
2558 "no '_' may survive in the slug: {slug}"
2559 );
2560 }
2561
2562 #[test]
2563 fn sessions_for_path_finds_session_under_dotted_symlinked_cwd() {
2564 let projects = tempfile::tempdir().unwrap();
2569 let work = tempfile::tempdir().unwrap();
2570 let cwd = work.path().join("tmp.XYZ");
2571 fs::create_dir_all(&cwd).unwrap();
2572
2573 let canonical = fs::canonicalize(&cwd).unwrap();
2577 let expected_slug = encode_path_slug(&canonical.to_string_lossy());
2578 let proj_dir = projects.path().join(&expected_slug);
2579 fs::create_dir_all(&proj_dir).unwrap();
2580 write_session(
2581 &proj_dir,
2582 "sess-dot",
2583 &[
2584 r#"{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","cwd":"x","message":{"role":"user","content":"hi"}}"#,
2585 ],
2586 );
2587
2588 let root = HistoryRoot::at(projects.path());
2589 let sessions = root.sessions_for_path(&cwd).expect("enumerate");
2590 assert_eq!(
2591 sessions.len(),
2592 1,
2593 "should find the session for the dotted/symlinked cwd"
2594 );
2595 assert_eq!(sessions[0].session_id, "sess-dot");
2596 }
2597}