use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Serialize};
use crate::session::Record;
const INDEX_FILE: &str = ".index.jsonl";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Summary {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<FixedOffset>>,
pub last_used: DateTime<FixedOffset>,
pub prompts: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_telling_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub log_bytes: u64,
}
pub fn is_terse(text: &str) -> bool {
let text = text.trim();
text.starts_with('/') || (text.chars().count() < 24 && text.split_whitespace().count() <= 4)
}
fn clip_prompt(text: &str) -> String {
text.chars().take(600).collect()
}
fn fold_input(summary: &mut Summary, text: &str) {
summary.prompts += 1;
if summary.first_prompt.is_none() {
summary.first_prompt = Some(clip_prompt(text));
}
if summary.first_telling_prompt.is_none() && !is_terse(text) {
summary.first_telling_prompt = Some(clip_prompt(text));
}
if let Some(previous) = summary.last_prompt.replace(clip_prompt(text))
&& !is_terse(&previous)
{
summary.context_prompt = Some(previous);
}
if summary.last_prompt.as_deref().is_none_or(|last| !is_terse(last)) {
summary.context_prompt = None;
}
}
fn missing_telling_prompt(summary: &Summary) -> bool {
summary.first_telling_prompt.is_none()
&& [summary.first_prompt.as_deref(), summary.last_prompt.as_deref(), summary.context_prompt.as_deref()]
.into_iter()
.flatten()
.any(|p| !is_terse(p))
}
fn fold_inputs_since(path: &Path, from: u64, summary: &mut Summary) -> Result<()> {
let mut file = fs::File::open(path).with_context(|| format!("read session log {}", path.display()))?;
file.seek(SeekFrom::Start(from)).with_context(|| format!("seek session log {}", path.display()))?;
let mut tail = Vec::new();
file.read_to_end(&mut tail).with_context(|| format!("read session log {}", path.display()))?;
let committed = tail.iter().rposition(|&b| b == b'\n').map(|i| i + 1).unwrap_or(0);
for line in tail[..committed].split(|&b| b == b'\n').filter(|line| !line.is_empty()) {
if !line.starts_with(br#"{"type":"input""#) {
continue;
}
if let Ok(Record::Input { text, .. }) = serde_json::from_slice::<Record>(line) {
fold_input(summary, &text);
}
}
Ok(())
}
pub fn summarize(path: &Path) -> Result<Summary> {
let bytes = fs::read(path).with_context(|| format!("read session log {}", path.display()))?;
let metadata = fs::metadata(path)?;
let last_used = DateTime::<Local>::from(metadata.modified()?).fixed_offset();
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
let mut summary = Summary {
id,
cwd: None,
model: None,
created_at: None,
last_used,
prompts: 0,
first_prompt: None,
first_telling_prompt: None,
last_prompt: None,
context_prompt: None,
title: None,
log_bytes: bytes.len() as u64,
};
crate::session::validate_id(&summary.id)
.with_context(|| format!("session log {} has an invalid id", path.display()))?;
let committed = bytes.iter().rposition(|&b| b == b'\n').map(|i| i + 1).unwrap_or(0);
let mut records = bytes[..committed].split(|&b| b == b'\n').filter(|line| !line.is_empty());
match records.next().map(serde_json::from_slice::<Record>) {
Some(Ok(Record::Session { version, id, created_at, cwd, model }))
if version == crate::session::FORMAT_VERSION && id == summary.id =>
{
summary.created_at = Some(created_at);
summary.cwd = cwd;
summary.model = model;
}
_ => anyhow::bail!("session log {} has no resumable header", path.display()),
}
for line in records {
if !line.starts_with(br#"{"type":"input""#) {
continue;
}
if let Ok(Record::Input { text, .. }) = serde_json::from_slice::<Record>(line) {
fold_input(&mut summary, &text);
}
}
Ok(summary)
}
fn index_path(dir: &Path) -> PathBuf {
dir.join(INDEX_FILE)
}
fn load(dir: &Path) -> (HashMap<String, Summary>, usize) {
let Ok(bytes) = fs::read(index_path(dir)) else { return Default::default() };
let mut map = HashMap::new();
let mut lines = 0;
for line in bytes.split(|&b| b == b'\n') {
let line = trim_ascii(line);
if line.is_empty() {
continue;
}
lines += 1;
if let Ok(mut summary) = serde_json::from_slice::<Summary>(line) {
if summary.title.is_none()
&& let Some(title) = map.get(&summary.id).and_then(|prev: &Summary| prev.title.clone())
{
summary.title = Some(title);
}
map.insert(summary.id.clone(), summary);
}
}
(map, lines)
}
fn trim_ascii(line: &[u8]) -> &[u8] {
let start = line.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(line.len());
let end = line.iter().rposition(|b| !b.is_ascii_whitespace()).map_or(start, |i| i + 1);
&line[start..end]
}
fn append(dir: &Path, summaries: &[Summary]) -> Result<()> {
if summaries.is_empty() {
return Ok(());
}
let mut file = OpenOptions::new().create(true).append(true).open(index_path(dir))?;
for summary in summaries {
let mut line = serde_json::to_vec(summary)?;
line.push(b'\n');
if file.write(&line)? != line.len() {
anyhow::bail!("short write to session index");
}
}
Ok(())
}
fn rewrite(dir: &Path, summaries: &HashMap<String, Summary>, since: u64) -> Result<()> {
let mut merged = summaries.clone();
if let Ok(mut file) = OpenOptions::new().read(true).open(index_path(dir))
&& let Ok(pos) = file.seek(SeekFrom::Start(since))
{
let mut rest = Vec::new();
if pos == since && file.read_to_end(&mut rest).is_ok() {
for line in rest.split(|&b| b == b'\n') {
let line = trim_ascii(line);
if line.is_empty() {
continue;
}
if let Ok(mut summary) = serde_json::from_slice::<Summary>(line) {
if summary.title.is_none() {
summary.title = merged.get(&summary.id).and_then(|m| m.title.clone());
}
merged.insert(summary.id.clone(), summary);
}
}
}
}
let mut text = String::new();
let mut sorted: Vec<&Summary> = merged.values().collect();
sorted.sort_by(|a, b| a.id.cmp(&b.id));
for summary in sorted {
text.push_str(&serde_json::to_string(summary)?);
text.push('\n');
}
let tmp = dir.join(format!("{INDEX_FILE}.tmp-{}", std::process::id()));
fs::write(&tmp, text)?;
fs::rename(&tmp, index_path(dir))?;
Ok(())
}
pub fn update(path: &Path, from: u64, model: Option<String>) -> Result<Summary> {
let dir = path.parent().context("session log has no directory")?;
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let metadata = fs::metadata(path)?;
let last_used = DateTime::<Local>::from(metadata.modified()?).fixed_offset();
let index_len = fs::metadata(index_path(dir)).map(|m| m.len()).unwrap_or(0);
let (mut index, lines) = load(dir);
let cached = index.remove(id);
let cached_title = cached.as_ref().and_then(|c| c.title.clone());
let mut summary = match cached {
Some(mut cached) if cached.log_bytes == from && !missing_telling_prompt(&cached) => {
fold_inputs_since(path, from, &mut cached)?;
cached
}
_ => summarize(path)?,
};
summary.last_used = last_used;
summary.log_bytes = metadata.len();
summary.title = summary.title.or(cached_title);
if model.is_some() {
summary.model = model;
}
if lines > index.len() * 2 + 64 {
index.insert(summary.id.clone(), summary.clone());
rewrite(dir, &index, index_len)?;
} else {
append(dir, std::slice::from_ref(&summary))?;
}
Ok(summary)
}
pub fn set_title(path: &Path, title: &str) -> Result<bool> {
let dir = path.parent().context("session log has no directory")?;
let mut summary = summarize(path)?;
let indexed = load(dir).0;
if indexed.get(&summary.id).and_then(|o| o.title.clone()).is_some() {
return Ok(false);
}
summary.model = indexed.get(&summary.id).and_then(|o| o.model.clone()).or(summary.model);
summary.title = Some(title.to_string());
append(dir, std::slice::from_ref(&summary))?;
Ok(true)
}
pub fn title_source(summary: &Summary) -> Option<String> {
let first = summary.first_telling_prompt.as_deref();
let last = [summary.last_prompt.as_deref(), summary.context_prompt.as_deref()]
.into_iter()
.flatten()
.find(|p| !is_terse(p));
match (first, last) {
(Some(first), Some(last)) if first != last => Some(format!("{first}\n\n{last}")),
(Some(one), _) | (None, Some(one)) => Some(one.to_string()),
(None, None) => None,
}
}
pub fn clean_title(reply: &str) -> Option<String> {
let wrapper = |c: char| matches!(c, '"' | '\'' | '`' | '*' | '#' | '_') || c.is_whitespace();
let line =
reply.lines().map(str::trim).find(|l| !l.is_empty())?.chars().filter(|c| !c.is_control()).collect::<String>();
let line = line.trim_matches(wrapper);
let line = line.strip_prefix("Title:").unwrap_or(line);
let line = line.trim_matches(wrapper);
let line = line.trim_end_matches('.').trim();
if line.is_empty() {
return None;
}
let words: Vec<&str> = line.split_whitespace().collect();
let mut title = words.iter().take(6).copied().collect::<Vec<_>>().join(" ");
let mut elided = words.len() > 6;
if title.chars().count() > 60 {
elided = true;
}
if elided {
title = title.chars().take(59).collect::<String>().trim_end().to_string();
title.push('…');
}
Some(title)
}
pub fn list(dir: &Path) -> Result<Vec<Summary>> {
let index_len = fs::metadata(index_path(dir)).map(|m| m.len()).unwrap_or(0);
let (mut index, lines) = load(dir);
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).with_context(|| format!("read session directory {}", dir.display())),
};
let mut current: HashMap<String, Summary> = HashMap::new();
let mut updates = Vec::new();
for entry in entries {
let entry = entry.with_context(|| format!("read entry in session directory {}", dir.display()))?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") || path.file_name() == Some(INDEX_FILE.as_ref()) {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_string) else { continue };
if crate::session::validate_id(&id).is_err() {
continue;
}
let metadata = entry.metadata().with_context(|| format!("read metadata for session log {}", path.display()))?;
if !metadata.is_file() {
continue;
}
let size = metadata.len();
let summary = match index.remove(&id) {
Some(summary) if summary.log_bytes == size => summary,
stale => match summarize(&path) {
Ok(mut fresh) => {
if let Some(old) = stale {
fresh.model = old.model.or(fresh.model);
fresh.title = fresh.title.or(old.title);
}
updates.push(fresh.clone());
fresh
}
Err(e) if e.chain().any(|c| c.is::<std::io::Error>()) => return Err(e),
Err(_) => continue,
},
};
current.insert(id, summary);
}
if lines + updates.len() > current.len() * 2 + 64 || !index.is_empty() {
let _ = rewrite(dir, ¤t, index_len);
} else {
let _ = append(dir, &updates);
}
let mut sessions: Vec<Summary> = current.into_values().filter(|s| s.prompts > 0).collect();
sessions.sort_by_key(|s| std::cmp::Reverse(s.last_used));
Ok(sessions)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{SessionLog, now};
fn input(log: &mut SessionLog, id: &str, text: &str) {
log.append(&Record::Input { id: id.into(), text: text.into(), recorded_at: now() }).unwrap();
}
fn summarize_text(prompts: &[&str]) -> Summary {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "t").unwrap();
for (i, prompt) in prompts.iter().enumerate() {
input(&mut log, &format!("i{i}"), prompt);
}
summarize(log.path()).unwrap()
}
#[test]
fn cleans_title_replies() {
assert_eq!(clean_title("\n \"Flaky deploy test fix.\"\nmore").as_deref(), Some("Flaky deploy test fix"));
assert_eq!(clean_title("Title: **Session picker**").as_deref(), Some("Session picker"));
assert_eq!(clean_title("**Title: Session picker**").as_deref(), Some("Session picker"));
assert_eq!(clean_title("\"Title: Session picker\"").as_deref(), Some("Session picker"));
assert_eq!(clean_title("_Session picker_").as_deref(), Some("Session picker"));
assert_eq!(clean_title("__Title: Session picker__").as_deref(), Some("Session picker"));
assert_eq!(clean_title(" \n ''"), None);
assert_eq!(clean_title("one two three four five six seven").as_deref(), Some("one two three four five six…"));
let long = clean_title(&"word ".repeat(30)).unwrap();
assert!(long.ends_with('…') && long.chars().count() <= 60, "{long}");
}
#[test]
fn clean_title_strips_terminal_control_characters() {
assert_eq!(clean_title("Fix\x1b[2J the bug").as_deref(), Some("Fix[2J the bug"));
assert_eq!(clean_title("Title\x1b]52;c;YQ==\x07here").as_deref(), Some("Title]52;c;YQ==here"));
assert_eq!(clean_title("a\x00\x07\x1bb").as_deref(), Some("ab"));
assert_eq!(clean_title("\x1b[2J\x07").as_deref(), Some("[2J"));
assert_eq!(clean_title("\x00\x07\x1b"), None);
}
#[test]
fn title_source_skips_terse_prompts() {
let mut s = summarize_text(&["hi", "do it"]);
assert_eq!(title_source(&s), None);
s = summarize_text(&["Fix the flaky deploy test", "do it"]);
assert_eq!(title_source(&s).as_deref(), Some("Fix the flaky deploy test"));
s = summarize_text(&["Fix the flaky deploy test", "Now write the release notes", "ok"]);
assert_eq!(title_source(&s).as_deref(), Some("Fix the flaky deploy test\n\nNow write the release notes"));
}
#[test]
fn prompts_are_clipped_to_the_title_input_size() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "s").unwrap();
let huge = "x".repeat(10_000);
input(&mut log, "i1", &huge);
let summary = summarize(log.path()).unwrap();
assert_eq!(summary.first_prompt.as_deref().map(str::len), Some(600));
assert_eq!(summary.first_telling_prompt.as_deref().map(str::len), Some(600));
assert_eq!(summary.last_prompt.as_deref().map(str::len), Some(600));
assert_eq!(title_source(&summary).as_deref(), Some(huge.chars().take(600).collect::<String>().as_str()));
}
#[test]
fn set_title_does_not_replace_an_existing_title() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "t").unwrap();
input(&mut log, "i1", "Fix the flaky deploy test");
assert!(set_title(log.path(), "First title").unwrap());
assert!(!set_title(log.path(), "Second title").unwrap());
assert_eq!(list(dir.path()).unwrap()[0].title.as_deref(), Some("First title"));
}
#[test]
fn title_survives_updates_and_rebuilds() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "t").unwrap();
input(&mut log, "i1", "Fix the flaky deploy test");
assert!(set_title(log.path(), "Flaky deploy test").unwrap());
update(log.path(), 0, Some("mock/x".into())).unwrap();
assert_eq!(list(dir.path()).unwrap()[0].title.as_deref(), Some("Flaky deploy test"));
input(&mut log, "i2", "Another prompt that changes the log");
let sessions = list(dir.path()).unwrap();
assert_eq!((sessions[0].prompts, sessions[0].title.as_deref()), (2, Some("Flaky deploy test")));
}
#[test]
fn terse_prompts() {
assert!(is_terse("Do it"));
assert!(is_terse(" yes please "));
assert!(is_terse("/model openai/gpt-5"));
assert!(!is_terse("Fix the flaky deploy test in CI"));
assert!(!is_terse("Refactor the session index"));
}
#[test]
fn summarizes_prompts_and_header() {
let dir = tempfile::tempdir().unwrap();
let mut log =
SessionLog::create_with(dir.path(), "s1", Some("/work/repo".into()), Some("openai/gpt-5".into())).unwrap();
input(&mut log, "i1", "Investigate the flaky deploy test");
input(&mut log, "i2", "Fix the retry logic in the deployer");
input(&mut log, "i3", "do it");
let path = log.path().to_path_buf();
drop(log);
let mut file = OpenOptions::new().append(true).open(&path).unwrap();
file.write_all(br#"{"type":"input","data":{"id":"i4","te"#).unwrap();
let summary = summarize(&path).unwrap();
assert_eq!(summary.id, "s1");
assert_eq!(summary.cwd.as_deref(), Some("/work/repo"));
assert_eq!(summary.model.as_deref(), Some("openai/gpt-5"));
assert_eq!(summary.prompts, 3);
assert_eq!(summary.first_prompt.as_deref(), Some("Investigate the flaky deploy test"));
assert_eq!(summary.last_prompt.as_deref(), Some("do it"));
assert_eq!(summary.context_prompt.as_deref(), Some("Fix the retry logic in the deployer"));
assert_eq!(summary.log_bytes, fs::metadata(&path).unwrap().len());
}
#[test]
fn no_context_when_last_prompt_is_telling() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "s").unwrap();
input(&mut log, "i1", "Investigate the flaky deploy test");
input(&mut log, "i2", "Now write the release notes for it");
let summary = summarize(log.path()).unwrap();
assert_eq!(summary.context_prompt, None);
assert_eq!(summary.cwd, None, "older logs have no cwd");
}
#[test]
fn summarize_rejects_logs_open_would_refuse() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "original").unwrap();
input(&mut log, "i1", "A prompt in the renamed log");
let original = log.path().to_path_buf();
drop(log);
let renamed = dir.path().join("renamed.jsonl");
fs::rename(&original, &renamed).unwrap();
assert!(summarize(&renamed).is_err(), "renamed log must not summarize");
let headerless = dir.path().join("headerless.jsonl");
fs::write(&headerless, b"{\"type\":\"input\",\"data\":{\"id\":\"i1\",\"text\":\"hi\"}}\n").unwrap();
assert!(summarize(&headerless).is_err(), "input-only log must not summarize");
let mut good = SessionLog::create(dir.path(), "good").unwrap();
input(&mut good, "i1", "A valid prompt here");
let summary = summarize(good.path()).unwrap();
assert_eq!(summary.id, "good");
assert_eq!(summary.prompts, 1);
}
#[test]
fn summarize_rejects_ids_open_would_refuse() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "good").unwrap();
input(&mut log, "i1", "A prompt in a badly named log");
let good = log.path().to_path_buf();
drop(log);
let hidden = dir.path().join(".hidden.jsonl");
fs::rename(&good, &hidden).unwrap();
assert!(summarize(&hidden).is_err(), "a log named with an invalid id must not summarize");
}
#[test]
fn list_indexes_skips_empty_and_refreshes_stale() {
let dir = tempfile::tempdir().unwrap();
let mut a = SessionLog::create(dir.path(), "a").unwrap();
input(&mut a, "i1", "First session prompt here");
SessionLog::create(dir.path(), "empty").unwrap();
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["a"]);
let (index, _) = load(dir.path());
assert_eq!(index.len(), 2, "empty sessions are indexed too, just not listed");
input(&mut a, "i2", "Second prompt for the session");
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions[0].prompts, 2);
assert_eq!(sessions[0].last_prompt.as_deref(), Some("Second prompt for the session"));
}
#[test]
fn cache_filename_cannot_collide_with_a_session_id() {
let stem = Path::new(INDEX_FILE).file_stem().and_then(|s| s.to_str()).unwrap();
assert!(crate::session::validate_id(stem).is_err(), "cache file stem {stem:?} must not be a valid session id");
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "index").unwrap();
input(&mut log, "i1", "A prompt from the session named index");
drop(log);
let sessions = list(dir.path()).unwrap();
assert_eq!(
sessions.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
["index"],
"a session named `index` must be listed, not hidden by the cache"
);
assert!(index_path(dir.path()).exists(), "the cache is written to {INDEX_FILE}");
}
#[test]
fn list_skips_invalid_id_even_on_cache_hit() {
let dir = tempfile::tempdir().unwrap();
let hidden = dir.path().join(".hidden.jsonl");
fs::write(&hidden, b"{\"type\":\"input\",\"data\":{\"id\":\"i1\",\"text\":\"hi\"}}\n").unwrap();
let size = fs::metadata(&hidden).unwrap().len();
let cached = Summary {
id: ".hidden".into(),
cwd: None,
model: None,
created_at: None,
last_used: crate::session::now(),
prompts: 1,
first_prompt: Some("hi".into()),
first_telling_prompt: None,
last_prompt: Some("hi".into()),
context_prompt: None,
title: None,
log_bytes: size,
};
append(dir.path(), &[cached]).unwrap();
assert!(list(dir.path()).unwrap().is_empty(), "an invalid id must not be listed from cache");
}
#[test]
fn list_propagates_directory_errors_except_not_found() {
let missing = tempfile::tempdir().unwrap();
let absent = missing.path().join("does-not-exist");
assert!(list(&absent).unwrap().is_empty(), "a missing directory means no sessions");
let dir = tempfile::tempdir().unwrap();
let not_a_dir = dir.path().join("a-file");
fs::write(¬_a_dir, b"x").unwrap();
assert!(list(¬_a_dir).is_err(), "a non-NotFound read_dir error must propagate");
}
#[test]
fn list_skips_non_regular_jsonl_entries() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "s").unwrap();
input(&mut log, "i1", "A real session prompt");
drop(log);
fs::create_dir(dir.path().join("notalog.jsonl")).unwrap();
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions.len(), 1, "the directory entry is skipped, the real log listed");
assert_eq!(sessions[0].id, "s");
}
#[test]
fn update_records_current_model_and_list_keeps_it() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create_with(dir.path(), "m", None, Some("openai/gpt-5".into())).unwrap();
input(&mut log, "i1", "Switch models halfway through");
update(log.path(), 0, Some("anthropic/claude".into())).unwrap();
assert_eq!(list(dir.path()).unwrap()[0].model.as_deref(), Some("anthropic/claude"));
input(&mut log, "i2", "Another prompt after switching");
assert_eq!(list(dir.path()).unwrap()[0].model.as_deref(), Some("anthropic/claude"));
}
#[test]
fn update_folds_the_turns_input_into_the_cached_summary() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create_with(dir.path(), "s", Some("/work/repo".into()), None).unwrap();
input(&mut log, "i1", "Investigate the flaky deploy test");
update(log.path(), 0, None).unwrap();
let scanned = list(dir.path()).unwrap();
assert_eq!(scanned[0].prompts, 1);
assert_eq!(scanned[0].first_prompt.as_deref(), Some("Investigate the flaky deploy test"));
let from = log.size();
input(&mut log, "i2", "Fix the retry logic in the deployer");
update(log.path(), from, None).unwrap();
let from = log.size();
input(&mut log, "i3", "do it");
update(log.path(), from, None).unwrap();
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions[0].prompts, 3);
assert_eq!(sessions[0].last_prompt.as_deref(), Some("do it"));
assert_eq!(sessions[0].context_prompt.as_deref(), Some("Fix the retry logic in the deployer"));
assert_eq!(sessions[0].log_bytes, log.size());
}
#[test]
fn update_rescans_a_legacy_line_missing_the_first_telling_prompt() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "s").unwrap();
input(&mut log, "i1", "Investigate the flaky deploy test");
let mut legacy = summarize(log.path()).unwrap();
legacy.first_telling_prompt = None;
append(dir.path(), &[legacy]).unwrap();
let from = log.size();
input(&mut log, "i2", "Now fix the retry logic in the deployer");
update(log.path(), from, None).unwrap();
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions[0].first_telling_prompt.as_deref(), Some("Investigate the flaky deploy test"));
assert_eq!(
title_source(&sessions[0]).as_deref(),
Some("Investigate the flaky deploy test\n\nNow fix the retry logic in the deployer"),
);
}
#[test]
fn update_folds_an_input_a_concurrent_writer_appended_since_from() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create_with(dir.path(), "s", Some("/work/repo".into()), None).unwrap();
input(&mut log, "i1", "First turn prompt");
update(log.path(), 0, None).unwrap();
assert_eq!(list(dir.path()).unwrap()[0].prompts, 1);
let from = log.size();
input(&mut log, "i2", "This process's prompt");
input(&mut log, "i3", "A concurrent process's prompt");
update(log.path(), from, None).unwrap();
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions[0].prompts, 3);
assert_eq!(sessions[0].last_prompt.as_deref(), Some("A concurrent process's prompt"));
assert_eq!(sessions[0].log_bytes, log.size());
assert_eq!(list(dir.path()).unwrap()[0].prompts, 3);
}
#[test]
fn update_rescans_when_the_cache_is_stale() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "s").unwrap();
input(&mut log, "i1", "First prompt for the session");
update(log.path(), u64::MAX, None).unwrap();
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions[0].prompts, 1);
assert_eq!(sessions[0].last_prompt.as_deref(), Some("First prompt for the session"));
}
#[test]
fn rewrite_merges_lines_appended_since_the_read() {
let dir = tempfile::tempdir().unwrap();
let mut a = SessionLog::create(dir.path(), "a").unwrap();
input(&mut a, "i1", "Session a's prompt");
let mut b = SessionLog::create(dir.path(), "b").unwrap();
input(&mut b, "i1", "Session b's prompt");
list(dir.path()).unwrap();
let (current, _) = load(dir.path());
let since = fs::metadata(index_path(dir.path())).unwrap().len();
let mut newer = summarize(b.path()).unwrap();
newer.model = Some("anthropic/claude".into());
append(dir.path(), &[newer]).unwrap();
rewrite(dir.path(), ¤t, since).unwrap();
let sessions = list(dir.path()).unwrap();
let b = sessions.iter().find(|s| s.id == "b").unwrap();
assert_eq!(b.model.as_deref(), Some("anthropic/claude"));
assert_eq!(sessions.len(), 2);
}
#[test]
fn rewrite_isolates_a_corrupt_tail_line_instead_of_discarding_later_ones() {
let dir = tempfile::tempdir().unwrap();
let mut a = SessionLog::create(dir.path(), "a").unwrap();
input(&mut a, "i1", "Session a's prompt");
let mut b = SessionLog::create(dir.path(), "b").unwrap();
input(&mut b, "i1", "Session b's prompt");
list(dir.path()).unwrap();
let (current, _) = load(dir.path());
let since = fs::metadata(index_path(dir.path())).unwrap().len();
let mut tail = b"\xff\xfe not utf8\n".to_vec();
let mut newer = summarize(b.path()).unwrap();
newer.model = Some("anthropic/claude".into());
let mut line = serde_json::to_vec(&newer).unwrap();
line.push(b'\n');
tail.extend_from_slice(&line);
let mut file = OpenOptions::new().append(true).open(index_path(dir.path())).unwrap();
file.write_all(&tail).unwrap();
drop(file);
rewrite(dir.path(), ¤t, since).unwrap();
let sessions = list(dir.path()).unwrap();
let b = sessions.iter().find(|s| s.id == "b").unwrap();
assert_eq!(b.model.as_deref(), Some("anthropic/claude"), "the valid line after the corrupt one survives");
assert_eq!(sessions.len(), 2);
}
#[test]
fn index_is_compacted_and_forgets_deleted_logs() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "k").unwrap();
input(&mut log, "i1", "Keep this session around");
for _ in 0..100 {
update(log.path(), 0, None).unwrap();
}
let gone = SessionLog::create(dir.path(), "gone").unwrap();
list(dir.path()).unwrap();
fs::remove_file(gone.path()).unwrap();
list(dir.path()).unwrap();
let (index, lines) = load(dir.path());
assert_eq!((index.len(), lines), (1, 1));
fs::write(index_path(dir.path()), "not json\n").unwrap();
assert_eq!(list(dir.path()).unwrap().len(), 1);
}
#[test]
fn update_compacts_the_index_without_a_listing() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create(dir.path(), "s").unwrap();
input(&mut log, "i1", "Keep this session around");
for _ in 0..200 {
update(log.path(), 0, None).unwrap();
}
let (index, lines) = load(dir.path());
assert_eq!(index.len(), 1, "one live session");
assert!(lines <= index.len() * 2 + 64, "index grew past the compaction threshold: {lines} lines");
let sessions = list(dir.path()).unwrap();
assert_eq!(sessions[0].id, "s");
assert_eq!(sessions[0].prompts, 1);
}
#[test]
fn load_isolates_a_non_utf8_line_instead_of_discarding_the_index() {
let dir = tempfile::tempdir().unwrap();
let mut a = SessionLog::create(dir.path(), "a").unwrap();
input(&mut a, "i1", "Session a's prompt");
let mut b = SessionLog::create(dir.path(), "b").unwrap();
input(&mut b, "i1", "Session b's prompt");
list(dir.path()).unwrap();
let (before, _) = load(dir.path());
assert_eq!(before.len(), 2);
let mut bytes = fs::read(index_path(dir.path())).unwrap();
bytes.extend_from_slice(b"\xff\xfe not utf8\n");
fs::write(index_path(dir.path()), bytes).unwrap();
let (after, lines) = load(dir.path());
assert_eq!(after.len(), 2, "the two good lines survive the corrupt one");
assert_eq!(lines, 3, "the corrupt line still counts toward compaction");
assert_eq!(after["a"].prompts, 1);
assert_eq!(after["b"].prompts, 1);
}
}