diff --git a/hive-claude/Cargo.toml b/hive-claude/Cargo.toml index 63e6f608..f9cd1756 100644 --- a/hive-claude/Cargo.toml +++ b/hive-claude/Cargo.toml @@ -11,3 +11,6 @@ serde = { workspace = true } serde_json.workspace = true thiserror.workspace = true tokio.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/hive-claude/src/store.rs b/hive-claude/src/store.rs index 064aa924..11e80545 100644 --- a/hive-claude/src/store.rs +++ b/hive-claude/src/store.rs @@ -50,7 +50,6 @@ impl SessionStore { /// (including anything already archived to `*.jsonl.archived`) are skipped. #[must_use] pub fn find_by_title(&self, title: &str) -> Option { - let marker = format!("\"customTitle\":\"{title}\""); for entry in std::fs::read_dir(self.project_dir()).ok()?.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { @@ -62,7 +61,7 @@ impl SessionStore { if std::io::BufReader::new(file) .lines() .map_while(std::result::Result::ok) - .any(|line| line.contains(&marker)) + .any(|line| line_sets_title(&line, title)) { return Some(path); } @@ -93,3 +92,81 @@ impl SessionStore { Ok(Some(target)) } } + +/// True if `line` is the session's `custom-title` event whose **top-level** +/// `customTitle` field equals `title`. +/// +/// Parsing the line (rather than substring-matching the whole transcript) is +/// what makes this robust: a message that merely *quotes* the marker in its +/// content has no top-level `customTitle` key, so it can't cause a false match +/// on the wrong session file; the compact-vs-spaced JSON form is irrelevant; +/// and titles containing `"` / `\` are handled by the parser. Verified shape +/// (claude 2.1.x): `{"type":"custom-title","customTitle":"",…}`. +/// +/// The `contains` pre-check keeps the common case cheap — only the rare line +/// mentioning `customTitle` is parsed as JSON, not every transcript line. +fn line_sets_title(line: &str, title: &str) -> bool { + if !line.contains("customTitle") { + return false; + } + let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else { + return false; + }; + value.get("customTitle").and_then(|t| t.as_str()) == Some(title) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &std::path::Path, name: &str, lines: &[&str]) { + std::fs::write(dir.join(name), lines.join("\n")).unwrap(); + } + + #[test] + fn find_by_title_matches_only_the_custom_title_event() { + let home = tempfile::tempdir().unwrap(); + let cwd = std::path::Path::new("/agents/iris/state"); + let store = SessionStore::new(home.path(), cwd); + let proj = store.project_dir(); + std::fs::create_dir_all(&proj).unwrap(); + + // The real titled session. + write( + &proj, + "real.jsonl", + &[ + r#"{"type":"summary","summary":"x"}"#, + r#"{"type":"custom-title","customTitle":"iris","sessionId":"real"}"#, + ], + ); + // A DIFFERENT session whose transcript merely quotes the marker string + // in message content — must NOT match. + write( + &proj, + "other.jsonl", + &[ + r#"{"type":"custom-title","customTitle":"someone-else","sessionId":"other"}"#, + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"the file had \"customTitle\":\"iris\" in it"}]}}"#, + ], + ); + + assert_eq!(store.find_by_title("iris"), Some(proj.join("real.jsonl"))); + assert_eq!(store.find_by_title("nobody"), None); + } + + #[test] + fn find_by_title_tolerates_spaced_json_and_escapes() { + let home = tempfile::tempdir().unwrap(); + let store = SessionStore::new(home.path(), std::path::Path::new("/x")); + let proj = store.project_dir(); + std::fs::create_dir_all(&proj).unwrap(); + // Spaced JSON form + a title needing JSON escaping. + write( + &proj, + "s.jsonl", + &[r#"{ "type": "custom-title", "customTitle": "a\"b" }"#], + ); + assert_eq!(store.find_by_title("a\"b"), Some(proj.join("s.jsonl"))); + } +}