use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::io::Cursor;
use ai_session_search::config::Config;
use ai_session_search::db::Db;
use ai_session_search::indexer;
use ai_session_search::models::{Message, MessageFilters, ParsedSession, SearchFilters};
use anyhow::Result;
fn msg_key(m: &Message) -> (String, Option<String>, String) {
(
m.role.as_str().to_string(),
m.tool_name.clone(),
m.content.clone(),
)
}
fn assert_tail_matches_full<F>(initial: &str, appended: &str, file_name: &str, parse: F)
where
F: Fn(Cursor<Vec<u8>>, &std::path::Path) -> Result<ParsedSession>,
{
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(file_name);
let final_bytes = format!("{initial}{appended}");
std::fs::write(&path, &final_bytes).unwrap();
let full = parse(Cursor::new(final_bytes.clone().into_bytes()), &path).unwrap();
std::fs::write(&path, initial).unwrap();
let offset = ai_session_search::tail::complete_prefix_offset(&path).unwrap();
let initial_parsed = parse(Cursor::new(initial.as_bytes().to_vec()), &path).unwrap();
std::fs::write(&path, &final_bytes).unwrap();
let tail = ai_session_search::tail::tail_parse(&path, offset, &parse)
.unwrap()
.expect("new complete lines were appended");
let full_keys: Vec<_> = full.messages.iter().map(msg_key).collect();
let init_keys: Vec<_> = initial_parsed.messages.iter().map(msg_key).collect();
let tail_keys: Vec<_> = tail.new_messages.iter().map(msg_key).collect();
let m0 = init_keys.len();
assert!(m0 > 0, "the initial parse must produce some messages");
assert!(
!tail_keys.is_empty(),
"the append must produce some new messages"
);
assert_eq!(
&full_keys[..m0],
&init_keys[..],
"prefix messages match the full parse"
);
assert_eq!(
&full_keys[m0..],
&tail_keys[..],
"appended messages match the full parse suffix"
);
assert_eq!(
full.file_edits.len(),
initial_parsed.file_edits.len() + tail.new_file_edits.len(),
"file-edit counts add up (prefix + appended == full)"
);
}
#[test]
fn claude_tail_matches_full_with_cross_boundary_tool_result() {
let adapter = ai_session_search::providers::claude::ClaudeAdapter::new(vec![]);
let initial = concat!(
r#"{"type":"user","sessionId":"s","timestamp":"2026-06-01T10:00:00Z","cwd":"/p","message":{"role":"user","content":[{"type":"text","text":"first prompt"}]}}"#,
"\n",
r#"{"type":"assistant","sessionId":"s","timestamp":"2026-06-01T10:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"running bash"},{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
"\n",
);
let appended = concat!(
r#"{"type":"user","sessionId":"s","timestamp":"2026-06-01T10:00:06Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"a.rs"}]}}"#,
"\n",
r#"{"type":"user","sessionId":"s","timestamp":"2026-06-01T10:01:00Z","message":{"role":"user","content":[{"type":"text","text":"second prompt"}]}}"#,
"\n",
);
assert_tail_matches_full(
initial,
appended,
"11111111-2222-3333-4444-555555555555.jsonl",
|c, p| adapter.parse_reader(c, p),
);
}
#[test]
fn codex_tail_matches_full_with_cross_boundary_call_output() {
let adapter = ai_session_search::providers::codex::CodexAdapter::new(vec![]);
let initial = concat!(
r#"{"type":"session_meta","payload":{"id":"019efd97-d602-7922-89dd-467272106505","timestamp":"2026-06-25T07:00:00.000Z","cwd":"/tmp/proj"}}"#,
"\n",
r#"{"timestamp":"2026-06-25T07:00:01.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"do the thing"}]}}"#,
"\n",
r#"{"timestamp":"2026-06-25T07:00:02.000Z","type":"response_item","payload":{"type":"function_call","name":"shell","call_id":"c1","arguments":"{}"}}"#,
"\n",
);
let appended = concat!(
r#"{"timestamp":"2026-06-25T07:00:03.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"shell ran ok"}}"#,
"\n",
r#"{"timestamp":"2026-06-25T07:00:04.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"next thing"}]}}"#,
"\n",
);
let file = "rollout-2026-06-25T03-04-06-019efd97-d602-7922-89dd-467272106505.jsonl";
assert_tail_matches_full(initial, appended, file, |c, p| adapter.parse_reader(c, p));
}
#[test]
fn cursor_tail_matches_full_with_cross_boundary_tool_result() {
let adapter = ai_session_search::providers::cursor::CursorAdapter::new(vec![]);
let initial = concat!(
r#"{"role":"user","message":{"content":[{"type":"text","text":"edit the file"}]}}"#,
"\n",
r#"{"role":"assistant","message":{"content":[{"type":"text","text":"editing"},{"type":"tool_use","id":"tu_e","name":"Edit","input":{"file_path":"/p/app.ts","old_string":"a","new_string":"b"}}]}}"#,
"\n",
);
let appended = concat!(
r#"{"role":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu_e","content":"edit applied"}]}}"#,
"\n",
r#"{"role":"user","message":{"content":[{"type":"text","text":"another change please"}]}}"#,
"\n",
);
assert_tail_matches_full(
initial,
appended,
"22222222-3333-4444-5555-666666666666.jsonl",
|c, p| adapter.parse_reader(c, p),
);
}
#[test]
fn antigravity_tail_matches_full() {
let adapter = ai_session_search::providers::antigravity::AntigravityAdapter::new(vec![]);
let initial = concat!(
r#"{"step_index":1,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-19T23:13:00Z","content":"hello agent"}"#,
"\n",
r#"{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-19T23:13:05Z","content":"hello user"}"#,
"\n",
);
let appended = concat!(
r#"{"step_index":3,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-19T23:14:00Z","content":"do more work"}"#,
"\n",
r#"{"step_index":4,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-19T23:14:05Z","content":"on it now"}"#,
"\n",
);
assert_tail_matches_full(initial, appended, "transcript.jsonl", |c, p| {
adapter.parse_reader(c, p)
});
}
#[test]
fn pi_tail_matches_full() {
let adapter = ai_session_search::providers::pi::PiAdapter::new(vec![]);
let initial = concat!(
r#"{"type":"session","version":3,"id":"019edbc9-83df-72a0-a95b-64e6d810ad75","timestamp":"2026-06-18T17:31:17.343Z","cwd":"/p"}"#,
"\n",
r#"{"type":"message","timestamp":"2026-06-18T17:31:32.922Z","message":{"role":"user","content":[{"type":"text","text":"edit some files"}]}}"#,
"\n",
r#"{"type":"message","timestamp":"2026-06-18T17:31:36.595Z","message":{"role":"assistant","content":[{"type":"text","text":"writing it"},{"type":"toolCall","id":"t1","name":"write","arguments":{"path":"src/new.ts","content":"export const x = 1;"}}]}}"#,
"\n",
);
let appended = concat!(
r#"{"type":"message","timestamp":"2026-06-18T17:31:40.000Z","message":{"role":"toolResult","toolCallId":"t1","toolName":"write","content":[{"type":"text","text":"file written"}],"isError":false}}"#,
"\n",
r#"{"type":"message","timestamp":"2026-06-18T17:31:44.000Z","message":{"role":"user","content":[{"type":"text","text":"now run the tests"}]}}"#,
"\n",
);
let file = "2026-06-18T17-31-17-343Z_019edbc9-83df-72a0-a95b-64e6d810ad75.jsonl";
assert_tail_matches_full(initial, appended, file, |c, p| adapter.parse_reader(c, p));
}
#[test]
fn prime_agent_tail_matches_full() {
let adapter = ai_session_search::providers::pi::PiAdapter::prime_agent(vec![]);
let initial = concat!(
r#"{"type":"session","version":3,"id":"019edbc9-83df-72a0-a95b-64e6d810ad75","timestamp":"2026-06-18T17:31:17.343Z","cwd":"/p"}"#,
"\n",
r#"{"type":"message","timestamp":"2026-06-18T17:31:32.922Z","message":{"role":"user","content":[{"type":"text","text":"inspect the project"}]}}"#,
"\n",
);
let appended = concat!(
r#"{"type":"message","timestamp":"2026-06-18T17:31:36.595Z","message":{"role":"assistant","content":[{"type":"text","text":"inspection complete"}]}}"#,
"\n",
);
assert_tail_matches_full(
initial,
appended,
"019edbc9-83df-72a0-a95b-64e6d810ad75.jsonl",
|c, p| adapter.parse_reader(c, p),
);
}
fn all_sessions() -> SearchFilters {
SearchFilters {
limit: 100,
..SearchFilters::default()
}
}
const INITIAL: &str = concat!(
r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:00Z","cwd":"/tmp/proj","message":{"role":"user","content":[{"type":"text","text":"first user prompt about the project"}]}}"#,
"\n",
r#"{"type":"assistant","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"running a bash command now"},{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
"\n",
r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:06Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"a.rs b.rs"}]}}"#,
"\n",
r#"{"type":"assistant","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:10Z","message":{"role":"assistant","content":[{"type":"text","text":"writing the file now"},{"type":"tool_use","id":"tu_2","name":"Write","input":{"file_path":"/tmp/proj/a.rs","content":"fn main() {}"}}]}}"#,
"\n",
r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:01:00Z","message":{"role":"user","content":[{"type":"text","text":"second user prompt continues the work"}]}}"#,
"\n",
);
const APPENDED: &str = concat!(
r#"{"type":"assistant","sessionId":"tail-sess","timestamp":"2026-06-01T10:02:00Z","message":{"role":"assistant","content":[{"type":"text","text":"running another command"},{"type":"tool_use","id":"tu_3","name":"Bash","input":{"command":"cargo test"}}]}}"#,
"\n",
r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:02:01Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_3","content":"test result ok"}]}}"#,
"\n",
r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:03:00Z","message":{"role":"user","content":[{"type":"text","text":"third user prompt after the append"}]}}"#,
"\n",
);
fn claude_only_config(root: &Path, projects: &Path) -> Config {
let mut cfg = Config::default();
cfg.providers.claude.enabled = true;
cfg.providers.claude.paths = vec![projects.to_string_lossy().to_string()];
cfg.providers.claude_desktop.enabled = false;
cfg.providers.codex.enabled = false;
cfg.providers.cursor.enabled = false;
cfg.providers.antigravity.enabled = false;
cfg.providers.pi.enabled = false;
cfg.providers.prime_agent.enabled = false;
cfg.providers.aistudio.enabled = false;
cfg.providers.gemini_cli.enabled = false;
cfg.index.db_path = Some(root.join("index.db").to_string_lossy().to_string());
cfg
}
type Row = (i64, String, Option<String>, String, Option<String>);
fn rows(db: &Db) -> Vec<Row> {
db.search_messages("", &MessageFilters::default())
.unwrap()
.into_iter()
.map(|h| {
(
h.seq,
h.role.as_str().to_string(),
h.tool_name.clone(),
h.content.clone(),
h.ts.map(|t| t.to_rfc3339()),
)
})
.collect()
}
fn append(path: &Path, bytes: &str) {
let mut f = OpenOptions::new().append(true).open(path).unwrap();
f.write_all(bytes.as_bytes()).unwrap();
}
#[test]
fn tail_append_matches_full_reindex() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/tail-sess.jsonl");
std::fs::write(&file, INITIAL).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, true, None).unwrap();
let initial_rows = rows(&db);
assert_eq!(
initial_rows.len(),
7,
"7 messages before the append (2 user, 2 assistant, 1 tool result, 2 tool-call inputs)"
);
append(&file, APPENDED);
let (_total, updated) = indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(updated, 1, "the grown file is re-touched exactly once");
let tail_rows = rows(&db);
assert_eq!(
tail_rows.len(),
11,
"4 new messages appended (1 tool-call input, 1 assistant, 1 tool result, 1 user)"
);
assert_eq!(
&tail_rows[..7],
&initial_rows[..],
"prefix rows unchanged by the tail append"
);
let dir2 = tempfile::tempdir().unwrap();
let projects2 = dir2.path().join("projects");
std::fs::create_dir_all(projects2.join("proj")).unwrap();
std::fs::write(
projects2.join("proj/tail-sess.jsonl"),
format!("{INITIAL}{APPENDED}"),
)
.unwrap();
let cfg2 = claude_only_config(dir2.path(), &projects2);
let full_db = Db::open(&cfg2.db_path()).unwrap();
indexer::reindex(&cfg2, &full_db, true, None).unwrap();
assert_eq!(
tail_rows,
rows(&full_db),
"tail-append rows == full-reindex rows"
);
assert_eq!(
db.message_count().unwrap(),
full_db.message_count().unwrap()
);
assert_eq!(
db.messages_fts_count().unwrap(),
db.message_count().unwrap(),
"FTS in sync"
);
assert_eq!(
db.file_edit_count().unwrap(),
full_db.file_edit_count().unwrap()
);
assert_eq!(db.file_edit_count().unwrap(), 0);
let tools: Vec<_> = tail_rows
.iter()
.filter(|r| r.1 == "tool")
.map(|r| r.2.clone())
.collect();
assert_eq!(
tools,
vec![
Some("Bash".to_string()),
Some("Bash".to_string()),
Some("Write".to_string()),
Some("Bash".to_string()),
Some("Bash".to_string())
]
);
let session = &db.list_recent(&all_sessions()).unwrap()[0];
let full_session = &full_db.list_recent(&all_sessions()).unwrap()[0];
assert_eq!(
session.title, full_session.title,
"title matches full reindex"
);
assert_eq!(
session.updated_at, full_session.updated_at,
"updated_at matches full reindex"
);
assert_eq!(session.message_count, Some(11));
}
#[test]
fn unterminated_record_completed_by_append_matches_full_reindex() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/tail-sess.jsonl");
let first = r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:00Z","cwd":"/tmp/proj","message":{"role":"user","content":[{"type":"text","text":"first complete record"}]}}"#;
let unterminated = r#"{"type":"assistant","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"valid record without newline"}]}}"#;
let appended = r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:01:00Z","message":{"role":"user","content":[{"type":"text","text":"new record after flush"}]}}"#;
std::fs::write(&file, format!("{first}\n{unterminated}")).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, true, None).unwrap();
assert_eq!(
rows(&db).len(),
2,
"the parser accepts the final valid record"
);
append(&file, &format!("\n{appended}\n"));
indexer::reindex(&cfg, &db, false, None).unwrap();
let incremental_rows = rows(&db);
let oracle_dir = tempfile::tempdir().unwrap();
let oracle_projects = oracle_dir.path().join("projects");
std::fs::create_dir_all(oracle_projects.join("proj")).unwrap();
std::fs::write(
oracle_projects.join("proj/tail-sess.jsonl"),
format!("{first}\n{unterminated}\n{appended}\n"),
)
.unwrap();
let oracle_cfg = claude_only_config(oracle_dir.path(), &oracle_projects);
let oracle_db = Db::open(&oracle_cfg.db_path()).unwrap();
indexer::reindex(&oracle_cfg, &oracle_db, true, None).unwrap();
assert_eq!(
incremental_rows,
rows(&oracle_db),
"completing an indexed final record must not duplicate it"
);
}
fn codex_only_config(root: &Path, codex_root: &Path) -> Config {
let mut cfg = Config::default();
cfg.providers.claude.enabled = false;
cfg.providers.claude_desktop.enabled = false;
cfg.providers.codex.enabled = true;
cfg.providers.codex.paths = vec![codex_root.to_string_lossy().to_string()];
cfg.providers.cursor.enabled = false;
cfg.providers.antigravity.enabled = false;
cfg.providers.pi.enabled = false;
cfg.providers.prime_agent.enabled = false;
cfg.providers.aistudio.enabled = false;
cfg.providers.gemini_cli.enabled = false;
cfg.index.db_path = Some(root.join("index.db").to_string_lossy().to_string());
cfg
}
const CODEX_FILE: &str = "rollout-2026-06-25T03-04-06-019efd97-d602-7922-89dd-467272106505.jsonl";
const CODEX_INITIAL: &str = concat!(
r#"{"type":"session_meta","payload":{"id":"019efd97-d602-7922-89dd-467272106505","timestamp":"2026-06-25T07:00:00.000Z","cwd":"/tmp/proj"}}"#,
"\n",
r#"{"timestamp":"2026-06-25T07:00:01.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"do the thing"}]}}"#,
"\n",
r#"{"timestamp":"2026-06-25T07:00:02.000Z","type":"response_item","payload":{"type":"function_call","name":"shell","call_id":"c1","arguments":"{}"}}"#,
"\n",
);
const CODEX_APPENDED: &str = concat!(
r#"{"timestamp":"2026-06-25T07:00:03.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"shell ran ok"}}"#,
"\n",
r#"{"timestamp":"2026-06-25T07:00:04.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"next thing please"}]}}"#,
"\n",
);
#[test]
fn codex_tail_reindex_matches_full_reindex() {
let dir = tempfile::tempdir().unwrap();
let codex_root = dir.path().join("codex");
std::fs::create_dir_all(&codex_root).unwrap();
let file = codex_root.join(CODEX_FILE);
std::fs::write(&file, CODEX_INITIAL).unwrap();
let cfg = codex_only_config(dir.path(), &codex_root);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, true, None).unwrap();
let before = rows(&db);
assert_eq!(
before.len(),
2,
"2 messages before the append (the user turn and tool-call input)"
);
append(&file, CODEX_APPENDED);
let (_total, updated) = indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(
updated, 1,
"the grown codex file is re-touched once via the tail path"
);
let after = rows(&db);
assert!(after.len() > before.len(), "the tail append added messages");
let tool = after
.iter()
.find(|r| r.1 == "tool")
.expect("tool output indexed");
assert_eq!(
tool.2,
Some("shell".to_string()),
"tool output tagged with shell across the boundary"
);
let dir2 = tempfile::tempdir().unwrap();
let codex_root2 = dir2.path().join("codex");
std::fs::create_dir_all(&codex_root2).unwrap();
std::fs::write(
codex_root2.join(CODEX_FILE),
format!("{CODEX_INITIAL}{CODEX_APPENDED}"),
)
.unwrap();
let cfg2 = codex_only_config(dir2.path(), &codex_root2);
let full_db = Db::open(&cfg2.db_path()).unwrap();
indexer::reindex(&cfg2, &full_db, true, None).unwrap();
assert_eq!(
after,
rows(&full_db),
"codex tail-append reindex == full reindex"
);
assert_eq!(
db.messages_fts_count().unwrap(),
db.message_count().unwrap(),
"FTS in sync"
);
}
#[test]
fn truncation_falls_back_to_full_parse() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/tail-sess.jsonl");
std::fs::write(&file, format!("{INITIAL}{APPENDED}")).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, true, None).unwrap();
assert_eq!(rows(&db).len(), 11);
std::fs::write(&file, INITIAL).unwrap();
indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(
rows(&db).len(),
7,
"shrink โ full reparse of the truncated file"
);
assert_eq!(
db.messages_fts_count().unwrap(),
db.message_count().unwrap()
);
}
#[test]
fn rewritten_head_falls_back_to_full_parse() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/tail-sess.jsonl");
std::fs::write(&file, INITIAL).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, true, None).unwrap();
let new_head = r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T09:00:00Z","cwd":"/tmp/proj","message":{"role":"user","content":[{"type":"text","text":"completely different opening prompt now"}]}}"#;
let rewritten = format!("{new_head}\n{APPENDED}{APPENDED}");
std::fs::write(&file, &rewritten).unwrap();
indexer::reindex(&cfg, &db, false, None).unwrap();
let dir2 = tempfile::tempdir().unwrap();
let projects2 = dir2.path().join("projects");
std::fs::create_dir_all(projects2.join("proj")).unwrap();
std::fs::write(projects2.join("proj/tail-sess.jsonl"), &rewritten).unwrap();
let cfg2 = claude_only_config(dir2.path(), &projects2);
let full_db = Db::open(&cfg2.db_path()).unwrap();
indexer::reindex(&cfg2, &full_db, true, None).unwrap();
assert_eq!(
rows(&db),
rows(&full_db),
"rewritten head โ full reparse equals oracle"
);
}
fn claude_message_line(content: &str) -> String {
format!(
"{{\"type\":\"user\",\"sessionId\":\"integrity-sess\",\"timestamp\":\"2026-06-01T10:00:00Z\",\"cwd\":\"/p\",\"message\":{{\"role\":\"user\",\"content\":{}}}}}\n",
serde_json::to_string(content).unwrap()
)
}
fn message_contents(db: &Db) -> Vec<String> {
db.read_session_messages(
&MessageFilters {
session_id: Some("claude:integrity-sess".to_string()),
..MessageFilters::default()
},
ai_session_search::db::MessageOrder::OldestFirst,
)
.unwrap()
.into_iter()
.map(|message| message.content)
.collect()
}
#[test]
fn total_reparse_failure_preserves_the_last_successful_archive() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/integrity-sess.jsonl");
std::fs::write(&file, claude_message_line("archived before failure")).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(message_contents(&db), ["archived before failure"]);
std::fs::write(&file, "{this is a partial JSON write\n").unwrap();
let (_seen, updated) = indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(updated, 0, "a failed parse must not publish a replacement");
assert_eq!(
message_contents(&db),
["archived before failure"],
"the last successful archive must remain searchable"
);
std::fs::write(&file, claude_message_line("recovered after failure")).unwrap();
let (_seen, updated) = indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(updated, 1, "the stale checkpoint must retry after recovery");
assert_eq!(message_contents(&db), ["recovered after failure"]);
}
#[test]
fn same_size_body_rewrite_past_the_head_fingerprint_is_reparsed() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/integrity-sess.jsonl");
let stable_head = claude_message_line(&"x".repeat(5_000));
let original = format!("{stable_head}{}", claude_message_line("body-aaaa"));
let rewritten = format!("{stable_head}{}", claude_message_line("body-bbbb"));
assert_eq!(original.len(), rewritten.len());
std::fs::write(&file, &original).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, false, None).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
std::fs::write(&file, &rewritten).unwrap();
let (_seen, updated) = indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(updated, 1);
let contents = message_contents(&db);
assert!(contents.iter().any(|content| content == "body-bbbb"));
assert!(!contents.iter().any(|content| content == "body-aaaa"));
}
#[test]
fn full_parse_replaces_a_changed_prefix_even_when_its_boundary_is_unchanged() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/integrity-sess.jsonl");
let initial = ["opening-old", "middle-stable", "boundary-stable"]
.into_iter()
.map(claude_message_line)
.collect::<String>();
std::fs::write(&file, initial).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, false, None).unwrap();
let rewritten = [
"opening-new",
"middle-stable",
"boundary-stable",
"appended-message",
]
.into_iter()
.map(claude_message_line)
.collect::<String>();
std::fs::write(&file, rewritten).unwrap();
let (_seen, updated) = indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(updated, 1);
assert_eq!(
message_contents(&db),
[
"opening-new",
"middle-stable",
"boundary-stable",
"appended-message",
]
);
}
#[test]
fn claude_desktop_sidecar_change_refreshes_without_audit_append() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("local-agent-mode-sessions");
let parent = root.join("install/account");
let session_dir = parent.join("local_meta-refresh");
std::fs::create_dir_all(&session_dir).unwrap();
let sidecar = parent.join("local_meta-refresh.json");
std::fs::write(
&sidecar,
r#"{"sessionId":"meta-refresh","cwd":"/tmp/old","title":"Old Title","createdAt":"2026-04-01T00:00:00Z","lastActivityAt":"2026-04-01T00:00:00Z"}"#,
)
.unwrap();
std::fs::write(
session_dir.join("audit.jsonl"),
r#"{"type":"user","session_id":"meta-refresh","message":{"role":"user","content":"hello desktop"},"_audit_timestamp":"2026-04-01T00:00:00Z"}"#,
)
.unwrap();
let cfg = claude_only_config(dir.path(), &root);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, false, None).unwrap();
let first = db
.resolve_session("claude-desktop:meta-refresh")
.unwrap()
.session;
assert_eq!(first.provider.as_str(), "claude-desktop");
assert_eq!(first.title.as_deref(), Some("Old Title"));
assert_eq!(first.cwd.as_deref(), Some("/tmp/old"));
assert_eq!(
db.search_messages(
"",
&MessageFilters {
providers: Some(vec![ai_session_search::models::Provider::ClaudeDesktop,]),
..MessageFilters::default()
},
)
.unwrap()
.len(),
1
);
assert_eq!(
db.search_messages(
"",
&MessageFilters {
providers: Some(vec![ai_session_search::models::Provider::Claude]),
..MessageFilters::default()
},
)
.unwrap()
.len(),
0,
"claude-desktop messages must not be returned by provider=claude"
);
std::fs::write(
&sidecar,
r#"{"sessionId":"meta-refresh","cwd":"/tmp/new","title":"New Title","createdAt":"2026-04-01T00:00:00Z","lastActivityAt":"2026-04-01T00:05:00Z"}"#,
)
.unwrap();
indexer::reindex(&cfg, &db, false, None).unwrap();
let refreshed = db
.resolve_session("claude-desktop:meta-refresh")
.unwrap()
.session;
assert_eq!(refreshed.title.as_deref(), Some("New Title"));
assert_eq!(refreshed.cwd.as_deref(), Some("/tmp/new"));
assert_eq!(
rows(&db),
vec![(
0,
"user".to_string(),
None,
"hello desktop".to_string(),
Some("2026-04-01T00:00:00+00:00".to_string())
)],
"sidecar-only refresh must not duplicate messages"
);
}
#[test]
#[ignore]
fn bench_incremental_vs_full_on_large_session() {
use std::time::Instant;
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/tail-sess.jsonl");
let mut big = String::with_capacity(42 * 1024 * 1024);
let turn = |i: usize| {
format!(
"{}\n{}\n",
format_args!(
r#"{{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:00Z","cwd":"/tmp/proj","message":{{"role":"user","content":[{{"type":"text","text":"user turn {i} with a reasonably long body of text to make the file sizable for the benchmark padding padding padding"}}]}}}}"#
),
format_args!(
r#"{{"type":"assistant","sessionId":"tail-sess","timestamp":"2026-06-01T10:00:01Z","message":{{"role":"assistant","content":[{{"type":"text","text":"assistant reply {i} also with a reasonably long body of text to make the file sizable for the benchmark padding padding padding"}}]}}}}"#
),
)
};
let mut i = 0;
while big.len() < 40 * 1024 * 1024 {
big.push_str(&turn(i));
i += 1;
}
std::fs::write(&file, &big).unwrap();
let mb = big.len() as f64 / (1024.0 * 1024.0);
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
let t0 = Instant::now();
indexer::reindex(&cfg, &db, true, None).unwrap();
let initial_full = t0.elapsed();
append(&file, &turn(i));
let t1 = Instant::now();
indexer::reindex(&cfg, &db, false, None).unwrap();
let incremental = t1.elapsed();
let t2 = Instant::now();
indexer::reindex(&cfg, &db, true, None).unwrap();
let full = t2.elapsed();
println!(
"tail-parse benchmark ({mb:.1} MB session):\n initial full index: {initial_full:?}\n \
incremental (tail) after append: {incremental:?}\n full reindex of final file: {full:?}\n \
speedup (full / incremental): {:.1}x",
full.as_secs_f64() / incremental.as_secs_f64().max(1e-9)
);
assert!(
incremental < full,
"incremental tail reindex must beat a full reindex"
);
}
#[test]
fn partial_trailing_line_is_indexed_only_once_complete() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/tail-sess.jsonl");
std::fs::write(&file, INITIAL).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, true, None).unwrap();
assert_eq!(rows(&db).len(), 7);
let partial = r#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:02:00Z","message":{"role":"user","content":[{"type":"text","text":"a half-written prompt"#;
append(&file, partial);
indexer::reindex(&cfg, &db, false, None).unwrap();
assert_eq!(rows(&db).len(), 7, "a partial line yields no message yet");
append(&file, "\"}]}}\n");
indexer::reindex(&cfg, &db, false, None).unwrap();
let r = rows(&db);
assert_eq!(r.len(), 8, "the completed line is appended once");
assert_eq!(
r[7].3, "a half-written prompt",
"the completed user message content is correct"
);
}
#[test]
fn tail_parse_invalid_utf8_recovered_in_tail_consistent_with_full() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(projects.join("proj")).unwrap();
let file = projects.join("proj/tail-sess.jsonl");
std::fs::write(&file, INITIAL).unwrap();
let cfg = claude_only_config(dir.path(), &projects);
let db = Db::open(&cfg.db_path()).unwrap();
indexer::reindex(&cfg, &db, true, None).unwrap();
assert_eq!(rows(&db).len(), 7);
{
let mut f = OpenOptions::new().append(true).open(&file).unwrap();
f.write_all(APPENDED.as_bytes()).unwrap();
let mut bad = br#"{"type":"user","sessionId":"tail-sess","timestamp":"2026-06-01T10:04:00Z","message":{"role":"user","content":[{"type":"text","text":"bad "#.to_vec();
bad.push(0xFF);
bad.extend_from_slice(br#" byte"}]}}"#);
bad.push(b'\n');
f.write_all(&bad).unwrap();
}
let res = indexer::reindex(&cfg, &db, false, None);
assert!(
res.is_ok(),
"an invalid-UTF-8 tail must be recovered, not abort the reindex: {res:?}"
);
let recovered = rows(&db);
assert_eq!(
recovered.len(),
12,
"7 initial + 4 appended + 1 recovered bad-byte line"
);
assert!(
recovered.iter().any(|r| r.3.contains('\u{FFFD}')),
"the invalid byte was recovered as U+FFFD, not dropped"
);
let dir2 = tempfile::tempdir().unwrap();
let projects2 = dir2.path().join("projects");
std::fs::create_dir_all(projects2.join("proj")).unwrap();
std::fs::copy(&file, projects2.join("proj/tail-sess.jsonl")).unwrap();
let cfg2 = claude_only_config(dir2.path(), &projects2);
let full_db = Db::open(&cfg2.db_path()).unwrap();
indexer::reindex(&cfg2, &full_db, true, None).unwrap();
assert_eq!(
rows(&db),
rows(&full_db),
"lossy-recovered tail-append index == full reindex of the same final file"
);
}
#[test]
fn codex_rollout_without_session_meta_indexes_as_a_point_span_and_the_reindex_continues() {
let dir = tempfile::tempdir().unwrap();
let codex_root = dir.path().join("codex");
std::fs::create_dir_all(&codex_root).unwrap();
const META_LESS: &str =
"rollout-2026-06-24T01-00-00-019efd00-0000-7000-8000-000000000001.jsonl";
std::fs::write(
codex_root.join(META_LESS),
concat!(
r#"{"timestamp":"2026-06-24T01:00:01.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"text","text":"start without meta"}]}}"#,
"\n",
r#"{"timestamp":"2026-06-24T01:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"working"}]}}"#,
"\n",
),
)
.unwrap();
std::fs::write(codex_root.join(CODEX_FILE), CODEX_INITIAL).unwrap();
let cfg = codex_only_config(dir.path(), &codex_root);
let db = Db::open(&cfg.db_path()).unwrap();
let (files_seen, sessions_updated) = indexer::reindex(&cfg, &db, true, None)
.expect("one span-less file must not stop the reindex");
assert_eq!(files_seen, 2);
assert_eq!(sessions_updated, 2, "the healthy sibling is indexed too");
let meta_less = db
.resolve_session_record("019efd00-0000-7000-8000-000000000001")
.unwrap();
let end = meta_less.updated_at.expect("the last activity is known");
assert_eq!(
meta_less.created_at,
Some(end),
"an unknown start becomes a point span at the known end, never the file mtime"
);
assert!(
meta_less.parse_warning.as_deref().unwrap_or("").is_empty(),
"a point span is ordinary, not a warning: {:?}",
meta_less.parse_warning
);
}