use crate::desktop::{DesktopSessionItem, DesktopSessionMessage};
use crate::lifecycle_store::LedgerEntry;
use serde::Deserialize;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use walkdir::WalkDir;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeSessionsIndexFile {
entries: Vec<ClaudeSessionIndexEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeSessionIndexEntry {
session_id: String,
full_path: String,
summary: Option<String>,
first_prompt: Option<String>,
message_count: Option<usize>,
created: Option<String>,
modified: Option<String>,
project_path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CodexSessionIndexEntry {
id: String,
thread_name: Option<String>,
updated_at: String,
}
#[derive(Default)]
struct CodexSessionMeta {
cwd: Option<String>,
prompt_preview: Option<String>,
updated_at: Option<String>,
}
pub struct ProviderSessionMessages {
pub messages: Vec<DesktopSessionMessage>,
pub total_messages: usize,
pub has_more_messages: bool,
}
const SESSION_MESSAGE_MAX_CHARS: usize = 2400;
const BARE_FILE_HEAD_LINES: usize = 20;
pub fn raw_session_id(session_id: &str) -> String {
session_id
.split_once(':')
.map(|(_, raw)| raw.to_string())
.unwrap_or_else(|| session_id.to_string())
}
pub fn build_memory_session_items(entries: &[LedgerEntry]) -> Vec<DesktopSessionItem> {
#[derive(Default)]
struct Aggregate {
last_recorded_at: String,
record_count: usize,
pending_review_count: usize,
wakeup_ready_count: usize,
titles: BTreeSet<String>,
memory_types: BTreeSet<String>,
}
let mut grouped: BTreeMap<String, Aggregate> = BTreeMap::new();
for entry in entries {
for session_id in entry_session_refs(entry) {
let aggregate = grouped.entry(session_id).or_default();
if entry.recorded_at > aggregate.last_recorded_at {
aggregate.last_recorded_at = entry.recorded_at.clone();
}
aggregate.record_count += 1;
if entry.record.requires_review() {
aggregate.pending_review_count += 1;
}
if entry.record.can_be_returned_in_wakeup() {
aggregate.wakeup_ready_count += 1;
}
aggregate.titles.insert(entry.record.title.clone());
aggregate
.memory_types
.insert(entry.record.memory_type.clone());
}
}
let mut sessions: Vec<DesktopSessionItem> = grouped
.into_iter()
.map(|(session_id, aggregate)| DesktopSessionItem {
provider: "spool".to_string(),
session_id: format!("spool:{session_id}"),
title: aggregate
.titles
.iter()
.next()
.cloned()
.unwrap_or_else(|| session_id.clone()),
summary: Some("来自 spool memory 记录的会话聚合".to_string()),
prompt_preview: aggregate.titles.iter().next().cloned(),
cwd: None,
source_path: None,
project_path: None,
updated_at: aggregate.last_recorded_at.clone(),
record_count: aggregate.record_count,
pending_review_count: aggregate.pending_review_count,
wakeup_ready_count: aggregate.wakeup_ready_count,
titles: aggregate.titles.into_iter().take(4).collect(),
memory_types: aggregate.memory_types.into_iter().collect(),
})
.collect();
sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
sessions
}
pub fn load_provider_sessions(filter: Option<&str>) -> anyhow::Result<Vec<DesktopSessionItem>> {
let mut sessions = Vec::new();
if filter.is_none() || filter == Some("claude") {
sessions.extend(load_claude_sessions()?);
}
if filter.is_none() || filter == Some("codex") {
sessions.extend(load_codex_sessions()?);
}
if filter.is_none() || filter == Some("gemini") {
sessions.extend(load_gemini_sessions()?);
}
sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
Ok(sessions)
}
pub fn load_provider_messages(
session: &DesktopSessionItem,
offset: usize,
limit: usize,
) -> anyhow::Result<ProviderSessionMessages> {
match session.provider.as_str() {
"claude" => load_claude_messages(session, offset, limit),
"codex" => load_codex_messages(session, offset, limit),
_ => Ok(ProviderSessionMessages {
messages: Vec::new(),
total_messages: 0,
has_more_messages: false,
}),
}
}
pub fn entry_session_refs(entry: &LedgerEntry) -> BTreeSet<String> {
let mut refs = BTreeSet::new();
collect_session_ref(entry.record.origin.source_ref.as_str(), &mut refs);
for evidence in &entry.metadata.evidence_refs {
collect_session_ref(evidence.as_str(), &mut refs);
}
refs
}
fn home_dir() -> Option<PathBuf> {
crate::support::home_dir()
}
fn load_claude_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
let Some(home) = home_dir() else {
return Ok(Vec::new());
};
let projects_root = home.join(".claude/projects");
if !projects_root.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
let mut indexed_paths: BTreeSet<String> = BTreeSet::new();
for entry in WalkDir::new(&projects_root).min_depth(1).max_depth(2) {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
if entry.file_name() != "sessions-index.json" {
continue;
}
let parsed: ClaudeSessionsIndexFile =
match serde_json::from_str(&fs::read_to_string(entry.path())?) {
Ok(value) => value,
Err(_) => continue,
};
for item in parsed.entries {
if fs::metadata(&item.full_path).is_err() {
continue;
}
indexed_paths.insert(item.full_path.clone());
let updated_at = item
.modified
.clone()
.or(item.created.clone())
.unwrap_or_else(|| "unknown".to_string());
let title = item
.summary
.clone()
.or(item.first_prompt.clone())
.unwrap_or_else(|| item.session_id.clone());
sessions.push(DesktopSessionItem {
provider: "claude".to_string(),
session_id: format!("claude:{}", item.session_id),
title,
summary: item.summary.clone().or(item.first_prompt.clone()),
prompt_preview: item.first_prompt.clone(),
cwd: item.project_path.clone(),
source_path: Some(item.full_path.clone()),
project_path: item.project_path,
updated_at,
record_count: item.message_count.unwrap_or(0),
pending_review_count: 0,
wakeup_ready_count: 0,
titles: Vec::new(),
memory_types: Vec::new(),
});
}
}
sessions.extend(load_claude_bare_sessions(&projects_root, &indexed_paths)?);
Ok(sessions)
}
fn load_claude_bare_sessions(
projects_root: &std::path::Path,
indexed_paths: &BTreeSet<String>,
) -> anyhow::Result<Vec<DesktopSessionItem>> {
let mut sessions = Vec::new();
for entry in WalkDir::new(projects_root)
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
continue;
}
let path_str = path.display().to_string();
if indexed_paths.contains(&path_str) {
continue;
}
if path
.file_name()
.map(|n| n == "sessions-index.json")
.unwrap_or(false)
{
continue;
}
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let project_path = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(decode_claude_project_slug);
let meta = extract_claude_bare_meta(path);
let title = meta.title.unwrap_or_else(|| {
meta.first_prompt
.clone()
.unwrap_or_else(|| session_id.clone())
});
let updated_at = meta.last_timestamp.unwrap_or_else(|| {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| {
let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
Some(format_unix_timestamp(duration.as_secs()))
})
.unwrap_or_else(|| "unknown".to_string())
});
sessions.push(DesktopSessionItem {
provider: "claude".to_string(),
session_id: format!("claude:{}", session_id),
title,
summary: meta.first_prompt.clone(),
prompt_preview: meta.first_prompt,
cwd: meta.cwd.or(project_path.clone()),
source_path: Some(path_str),
project_path,
updated_at,
record_count: 0,
pending_review_count: 0,
wakeup_ready_count: 0,
titles: Vec::new(),
memory_types: Vec::new(),
});
}
Ok(sessions)
}
#[derive(Default)]
struct ClaudeBareFileMeta {
title: Option<String>,
first_prompt: Option<String>,
cwd: Option<String>,
last_timestamp: Option<String>,
}
fn extract_claude_bare_meta(path: &std::path::Path) -> ClaudeBareFileMeta {
let mut meta = ClaudeBareFileMeta::default();
let file = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return meta,
};
let reader = BufReader::new(file);
for (idx, line) in reader.lines().enumerate() {
if idx >= BARE_FILE_HEAD_LINES {
break;
}
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
meta.last_timestamp = Some(ts.to_string());
}
match value.get("type").and_then(Value::as_str) {
Some("ai-title") if meta.title.is_none() => {
meta.title = value
.get("aiTitle")
.and_then(Value::as_str)
.map(truncate_for_preview);
}
Some("user") => {
if meta.first_prompt.is_none() {
meta.first_prompt =
extract_content_text(&value).map(|s| truncate_for_preview(&s));
}
if meta.cwd.is_none() {
meta.cwd = value
.get("cwd")
.and_then(Value::as_str)
.map(ToString::to_string);
}
}
_ => {}
}
if meta.title.is_some() && meta.first_prompt.is_some() && meta.cwd.is_some() {
break;
}
}
meta
}
fn decode_claude_project_slug(slug: &str) -> String {
if slug.starts_with('-') {
slug.replacen('-', "/", 1).replace('-', "/")
} else {
slug.replace('-', "/")
}
}
fn load_codex_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
let Some(home) = home_dir() else {
return Ok(Vec::new());
};
let sessions_root = home.join(".codex/sessions");
let index_lookup = load_codex_index_lookup(&home);
if !sessions_root.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in WalkDir::new(&sessions_root)
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
continue;
}
let Some(name) = path.file_stem().and_then(|name| name.to_str()) else {
continue;
};
let id = extract_codex_uuid(name);
let Some(id) = id else {
continue;
};
let path_str = path.display().to_string();
let meta = load_codex_session_meta(&path_str);
let index_entry = index_lookup.get(id);
let thread_name = index_entry.and_then(|e| e.thread_name.clone());
let index_updated_at = index_entry.map(|e| e.updated_at.clone());
let updated_at = meta.updated_at.or(index_updated_at).unwrap_or_else(|| {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| {
let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
Some(format_unix_timestamp(duration.as_secs()))
})
.unwrap_or_else(|| "unknown".to_string())
});
let title = meta
.prompt_preview
.clone()
.or(thread_name.clone())
.unwrap_or_else(|| id.to_string());
sessions.push(DesktopSessionItem {
provider: "codex".to_string(),
session_id: format!("codex:{}", id),
title,
summary: thread_name.or(meta.prompt_preview.clone()),
prompt_preview: meta.prompt_preview,
cwd: meta.cwd,
source_path: Some(path_str),
project_path: None,
updated_at,
record_count: 0,
pending_review_count: 0,
wakeup_ready_count: 0,
titles: Vec::new(),
memory_types: Vec::new(),
});
}
Ok(sessions)
}
fn extract_codex_uuid(name: &str) -> Option<&str> {
if name.len() < 36 {
return None;
}
let candidate = &name[name.len() - 36..];
let parts: Vec<&str> = candidate.split('-').collect();
if parts.len() != 5 {
return None;
}
let expected_lens = [8, 4, 4, 4, 12];
for (part, &expected) in parts.iter().zip(&expected_lens) {
if part.len() != expected || !part.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
}
Some(candidate)
}
fn load_codex_index_lookup(home: &std::path::Path) -> BTreeMap<String, CodexSessionIndexEntry> {
let index_path = home.join(".codex/session_index.jsonl");
let mut lookup = BTreeMap::new();
let content = match fs::read_to_string(index_path) {
Ok(c) => c,
Err(_) => return lookup,
};
for line in content.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(entry) = serde_json::from_str::<CodexSessionIndexEntry>(line) {
lookup.insert(entry.id.clone(), entry);
}
}
lookup
}
fn load_codex_session_meta(path: &str) -> CodexSessionMeta {
let mut meta = CodexSessionMeta::default();
let file = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return meta,
};
let reader = BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(value) => value,
Err(_) => continue,
};
if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
meta.updated_at = Some(ts.to_string());
}
match value.get("type").and_then(Value::as_str) {
Some("session_meta") => {
meta.cwd = value
.get("payload")
.and_then(|payload| payload.get("cwd"))
.and_then(Value::as_str)
.map(ToString::to_string);
}
Some("response_item") => {
let payload = value.get("payload");
let role = payload
.and_then(|payload| payload.get("role"))
.and_then(Value::as_str);
if role == Some("user") && meta.prompt_preview.is_none() {
meta.prompt_preview = payload
.and_then(|payload| payload.get("content"))
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find_map(|item| {
item.get("text")
.and_then(Value::as_str)
.map(truncate_for_preview)
})
});
}
}
_ => {}
}
if meta.cwd.is_some() && meta.prompt_preview.is_some() {
break;
}
}
meta
}
fn load_gemini_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
let Some(home) = home_dir() else {
return Ok(Vec::new());
};
let history_root = home.join(".gemini/history");
if !history_root.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in WalkDir::new(&history_root)
.min_depth(1)
.max_depth(3)
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "json" && ext != "jsonl" {
continue;
}
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let updated_at = fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| {
let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
Some(format_unix_timestamp(duration.as_secs()))
})
.unwrap_or_else(|| "unknown".to_string());
sessions.push(DesktopSessionItem {
provider: "gemini".to_string(),
session_id: format!("gemini:{}", session_id),
title: session_id.clone(),
summary: None,
prompt_preview: None,
cwd: None,
source_path: Some(path.display().to_string()),
project_path: None,
updated_at,
record_count: 0,
pending_review_count: 0,
wakeup_ready_count: 0,
titles: Vec::new(),
memory_types: Vec::new(),
});
}
Ok(sessions)
}
fn load_claude_messages(
session: &DesktopSessionItem,
offset: usize,
limit: usize,
) -> anyhow::Result<ProviderSessionMessages> {
let Some(path) = session.source_path.as_deref() else {
return Ok(ProviderSessionMessages {
messages: Vec::new(),
total_messages: 0,
has_more_messages: false,
});
};
let mut messages = Vec::new();
for line in fs::read_to_string(path)?
.lines()
.filter(|line| !line.trim().is_empty())
{
let value: Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(_) => continue,
};
let msg_type = value.get("type").and_then(Value::as_str);
match msg_type {
Some("user") | Some("assistant") => {
let role = msg_type.unwrap();
let content_text = extract_content_text(&value).unwrap_or_default();
if content_text.is_empty() {
continue;
}
messages.push(DesktopSessionMessage {
role: role.to_string(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
content: truncate_for_detail(&content_text),
truncated: is_content_truncated(&content_text),
});
}
_ => {
let role = value
.get("message")
.and_then(|message| message.get("role"))
.and_then(Value::as_str);
let content = value
.get("message")
.and_then(|message| message.get("content"))
.and_then(Value::as_str);
if let (Some(role), Some(content)) = (role, content) {
messages.push(DesktopSessionMessage {
role: role.to_string(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
content: truncate_for_detail(content),
truncated: is_content_truncated(content),
});
}
}
}
}
Ok(paginate_messages(messages, offset, limit))
}
fn load_codex_messages(
session: &DesktopSessionItem,
offset: usize,
limit: usize,
) -> anyhow::Result<ProviderSessionMessages> {
let Some(path) = session.source_path.as_deref() else {
return Ok(ProviderSessionMessages {
messages: Vec::new(),
total_messages: 0,
has_more_messages: false,
});
};
let mut messages = Vec::new();
for line in fs::read_to_string(path)?
.lines()
.filter(|line| !line.trim().is_empty())
{
let value: Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(_) => continue,
};
let message_payload = value
.get("payload")
.filter(|_| value.get("type").and_then(Value::as_str) == Some("response_item"));
let Some(payload) = message_payload else {
continue;
};
if payload.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
let Some(role) = payload.get("role").and_then(Value::as_str) else {
continue;
};
let text = payload
.get("content")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find_map(|item| {
item.get("text")
.and_then(Value::as_str)
.map(ToString::to_string)
})
});
if let Some(text) = text {
messages.push(DesktopSessionMessage {
role: role.to_string(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
content: truncate_for_detail(&text),
truncated: is_content_truncated(&text),
});
}
}
Ok(paginate_messages(messages, offset, limit))
}
fn collect_session_ref(value: &str, refs: &mut BTreeSet<String>) {
if value.starts_with("session:") {
refs.insert(value.to_string());
}
}
fn extract_content_text(value: &Value) -> Option<String> {
let message = value.get("message")?;
let content = message.get("content")?;
if let Some(arr) = content.as_array() {
let texts: Vec<&str> = arr
.iter()
.filter_map(|item| item.get("text").and_then(Value::as_str))
.collect();
if texts.is_empty() {
return None;
}
return Some(texts.join("\n"));
}
content.as_str().map(ToString::to_string)
}
fn truncate_for_preview(value: &str) -> String {
let trimmed = value.trim();
if trimmed.chars().count() <= 360 {
return trimmed.to_string();
}
trimmed.chars().take(360).collect::<String>() + "..."
}
fn truncate_for_detail(value: &str) -> String {
let trimmed = value.trim();
if trimmed.chars().count() <= SESSION_MESSAGE_MAX_CHARS {
return trimmed.to_string();
}
trimmed
.chars()
.take(SESSION_MESSAGE_MAX_CHARS)
.collect::<String>()
+ "\n\n...[truncated]"
}
fn is_content_truncated(value: &str) -> bool {
value.trim().chars().count() > SESSION_MESSAGE_MAX_CHARS
}
fn paginate_messages(
messages: Vec<DesktopSessionMessage>,
offset: usize,
limit: usize,
) -> ProviderSessionMessages {
let total_messages = messages.len();
let effective_limit = if limit == 0 { total_messages } else { limit };
let start = offset.min(total_messages);
let end = (start + effective_limit).min(total_messages);
let paged: Vec<DesktopSessionMessage> =
messages.into_iter().skip(start).take(end - start).collect();
let has_more_messages = end < total_messages;
ProviderSessionMessages {
messages: paged,
total_messages,
has_more_messages,
}
}
fn format_unix_timestamp(secs: u64) -> String {
let days_since_epoch = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let mut remaining_days = days_since_epoch as i64;
let mut year = 1970i64;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let days_in_months: [i64; 12] = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u32;
for &days_in_month in &days_in_months {
if remaining_days < days_in_month {
break;
}
remaining_days -= days_in_month;
month += 1;
}
let day = remaining_days + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn test_session(provider: &str, path: &std::path::Path) -> DesktopSessionItem {
DesktopSessionItem {
provider: provider.to_string(),
session_id: format!("{provider}:demo"),
title: "demo".to_string(),
summary: None,
prompt_preview: None,
cwd: Some("/tmp/demo".to_string()),
source_path: Some(path.display().to_string()),
project_path: Some("/tmp/demo".to_string()),
updated_at: "2026-04-16T12:00:00Z".to_string(),
record_count: 0,
pending_review_count: 0,
wakeup_ready_count: 0,
titles: Vec::new(),
memory_types: Vec::new(),
}
}
#[test]
fn claude_message_loader_should_paginate_with_offset_and_limit() {
let temp = tempdir().unwrap();
let path = temp.path().join("claude-session.jsonl");
let mut lines = Vec::new();
for index in 0..30 {
lines.push(format!(
"{{\"timestamp\":\"2026-04-16T12:{index:02}:00Z\",\"message\":{{\"role\":\"user\",\"content\":\"message {index} {}\"}}}}",
"x".repeat(32)
));
}
fs::write(&path, lines.join("\n")).unwrap();
let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
assert_eq!(response.total_messages, 30);
assert!(!response.has_more_messages);
assert_eq!(response.messages.len(), 30);
let response = load_claude_messages(&test_session("claude", &path), 0, 10).unwrap();
assert_eq!(response.total_messages, 30);
assert!(response.has_more_messages);
assert_eq!(response.messages.len(), 10);
assert_eq!(
response.messages.first().unwrap().content,
format!("message 0 {}", "x".repeat(32))
);
let response = load_claude_messages(&test_session("claude", &path), 25, 10).unwrap();
assert_eq!(response.total_messages, 30);
assert!(!response.has_more_messages);
assert_eq!(response.messages.len(), 5);
assert_eq!(
response.messages.first().unwrap().content,
format!("message 25 {}", "x".repeat(32))
);
}
#[test]
fn claude_message_loader_should_handle_new_format() {
let temp = tempdir().unwrap();
let path = temp.path().join("claude-new.jsonl");
let lines = [
r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"hello world"}]},"cwd":"/tmp","sessionId":"abc123"}"#,
r#"{"type":"assistant","timestamp":"2026-05-01T10:01:00Z","message":{"content":[{"type":"text","text":"hi there"}]},"sessionId":"abc123"}"#,
r#"{"type":"ai-title","aiTitle":"Test Session","sessionId":"abc123"}"#,
];
fs::write(&path, lines.join("\n")).unwrap();
let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
assert_eq!(response.total_messages, 2);
assert_eq!(response.messages[0].role, "user");
assert_eq!(response.messages[0].content, "hello world");
assert_eq!(response.messages[1].role, "assistant");
assert_eq!(response.messages[1].content, "hi there");
}
#[test]
fn codex_message_loader_should_mark_truncated_detail_content() {
let temp = tempdir().unwrap();
let path = temp.path().join("codex-session.jsonl");
let long_text = "y".repeat(3000);
fs::write(
&path,
format!(
"{{\"timestamp\":\"2026-04-16T12:00:00Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"text\":\"{}\"}}]}}}}",
long_text
),
)
.unwrap();
let response = load_codex_messages(&test_session("codex", &path), 0, 0).unwrap();
assert_eq!(response.total_messages, 1);
assert!(!response.has_more_messages);
assert_eq!(response.messages.len(), 1);
assert!(response.messages[0].truncated);
assert!(response.messages[0].content.ends_with("...[truncated]"));
}
#[test]
fn extract_claude_bare_meta_should_parse_new_format() {
let temp = tempdir().unwrap();
let path = temp.path().join("session.jsonl");
let lines = [
r#"{"type":"ai-title","aiTitle":"My Session Title","sessionId":"abc"}"#,
r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"first prompt here"}]},"cwd":"/home/user/project","sessionId":"abc"}"#,
];
fs::write(&path, lines.join("\n")).unwrap();
let meta = extract_claude_bare_meta(&path);
assert_eq!(meta.title.as_deref(), Some("My Session Title"));
assert_eq!(meta.first_prompt.as_deref(), Some("first prompt here"));
assert_eq!(meta.cwd.as_deref(), Some("/home/user/project"));
assert_eq!(meta.last_timestamp.as_deref(), Some("2026-05-01T10:00:00Z"));
}
#[test]
fn decode_claude_project_slug_should_restore_path() {
assert_eq!(
decode_claude_project_slug("-Users-long-Work-spool"),
"/Users/long/Work/spool"
);
assert_eq!(
decode_claude_project_slug("home-user-project"),
"home/user/project"
);
}
#[test]
fn paginate_messages_should_handle_edge_cases() {
let msgs: Vec<DesktopSessionMessage> = (0..5)
.map(|i| DesktopSessionMessage {
role: "user".to_string(),
timestamp: format!("t{i}"),
content: format!("msg{i}"),
truncated: false,
})
.collect();
let result = paginate_messages(msgs.clone(), 10, 5);
assert_eq!(result.messages.len(), 0);
assert!(!result.has_more_messages);
assert_eq!(result.total_messages, 5);
let result = paginate_messages(msgs.clone(), 0, 0);
assert_eq!(result.messages.len(), 5);
assert!(!result.has_more_messages);
let result = paginate_messages(msgs, 3, 10);
assert_eq!(result.messages.len(), 2);
assert!(!result.has_more_messages);
}
#[test]
fn codex_session_scan_should_find_files_without_index() {
let temp = tempdir().unwrap();
let sessions_dir = temp.path().join("sessions/2026/05/01");
fs::create_dir_all(&sessions_dir).unwrap();
let uuid = "019e0698-6647-7681-8fe3-bafa985c83df";
let filename = format!("rollout-2026-05-01T10-00-00-{uuid}.jsonl");
let session_path = sessions_dir.join(&filename);
fs::write(
&session_path,
r#"{"type":"session_meta","payload":{"cwd":"/tmp/test"}}
{"type":"response_item","timestamp":"2026-05-01T10:00:00Z","payload":{"type":"message","role":"user","content":[{"text":"hello codex"}]}}"#,
)
.unwrap();
let meta = load_codex_session_meta(&session_path.display().to_string());
assert_eq!(meta.cwd.as_deref(), Some("/tmp/test"));
assert_eq!(meta.prompt_preview.as_deref(), Some("hello codex"));
}
#[test]
fn extract_codex_uuid_should_parse_real_filenames() {
assert_eq!(
extract_codex_uuid("rollout-2026-03-26T15-18-28-019d2902-48c6-71f1-a107-6cb56512de80"),
Some("019d2902-48c6-71f1-a107-6cb56512de80")
);
assert_eq!(
extract_codex_uuid("rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"),
Some("019e0698-6647-7681-8fe3-bafa985c83df")
);
assert_eq!(extract_codex_uuid("short"), None);
assert_eq!(
extract_codex_uuid("rollout-2026-05-08T15-58-31-not-a-valid-uuid-at-all-here"),
None
);
}
}