use std::fs;
use tempfile::TempDir;
use vtcode_exec_events::{
ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
};
use crate::event_log::{DEFAULT_MAX_EVENTS, SessionEventLog};
use crate::migration::migrate_legacy;
use crate::query::{query_facts, recent_sessions};
use crate::{open, retention::apply_retention, sessions_root};
fn sample_turn() -> Vec<ThreadEvent> {
vec![
ThreadEvent::TurnStarted(TurnStartedEvent::default()),
ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }),
]
}
#[test]
fn append_and_reconstruct_roundtrip() {
let dir = TempDir::new().expect("tempdir");
let log = open(dir.path(), "sess-1", DEFAULT_MAX_EVENTS).expect("open");
for _ in 0..3 {
for e in &sample_turn() {
log.append(e).expect("append");
}
}
assert_eq!(log.turn_count(), 3);
let rebuilt = log.reconstruct_turn(2).expect("reconstruct");
assert_eq!(rebuilt.len(), 2);
assert!(matches!(rebuilt[0], ThreadEvent::TurnStarted(_)));
assert!(matches!(rebuilt[1], ThreadEvent::TurnCompleted(_)));
}
#[test]
fn event_log_batches_appends_until_turn_boundary() {
let dir = TempDir::new().expect("tempdir");
let log = open(dir.path(), "sess-buffered", DEFAULT_MAX_EVENTS).expect("open");
let events_path = sessions_root(dir.path()).join("sess-buffered").join("events.jsonl");
log.append(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }))
.expect("append thread event");
assert_eq!(fs::metadata(&events_path).expect("metadata").len(), 0);
log.append(&ThreadEvent::TurnStarted(TurnStartedEvent::default()))
.expect("append turn start");
assert_eq!(fs::metadata(&events_path).expect("metadata").len(), 0);
log.append(&ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }))
.expect("append turn completion");
assert!(fs::metadata(&events_path).expect("metadata").len() > 0);
assert_eq!(log.reconstruct_turn(1).expect("reconstruct").len(), 2);
}
#[test]
fn large_buffer_flush_persists_manifest_progress() {
let dir = TempDir::new().expect("tempdir");
let log = open(dir.path(), "sess-large-buffer", DEFAULT_MAX_EVENTS).expect("open");
for index in 0..1_000 {
log.append(&ThreadEvent::ThreadStarted(ThreadStartedEvent {
thread_id: format!("thread-{index:04}-buffer-boundary"),
}))
.expect("append event");
}
let manifest_path = sessions_root(dir.path()).join("sess-large-buffer").join("manifest.json");
let manifest: crate::SessionManifest =
serde_json::from_str(&fs::read_to_string(manifest_path).expect("read manifest")).expect("parse manifest");
assert!(manifest.event_count > 0);
assert!(manifest.event_count < 1_000);
}
#[test]
fn flushing_mid_turn_persists_buffered_metadata_for_reopen() {
let dir = TempDir::new().expect("tempdir");
{
let log = open(dir.path(), "sess-mid-turn", DEFAULT_MAX_EVENTS).expect("open");
log.append(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }))
.expect("append thread event");
log.append(&ThreadEvent::TurnStarted(TurnStartedEvent::default()))
.expect("append turn start");
log.flush().expect("flush mid-turn event log");
}
let reopened = open(dir.path(), "sess-mid-turn", DEFAULT_MAX_EVENTS).expect("reopen");
assert_eq!(reopened.event_count(), 2);
assert_eq!(reopened.turn_count(), 0);
assert_eq!(reopened.reconstruct_turn(1).expect("reconstruct open turn").len(), 1);
}
#[test]
fn index_rebuilt_on_reopen() {
let dir = TempDir::new().expect("tempdir");
{
let log = open(dir.path(), "sess-2", DEFAULT_MAX_EVENTS).expect("open");
for e in &sample_turn() {
log.append(e).expect("append");
}
log.complete().expect("complete");
}
let log = SessionEventLog::open(dir.path(), "sess-2", DEFAULT_MAX_EVENTS).expect("reopen");
assert_eq!(log.turn_count(), 1);
let rebuilt = log.reconstruct_turn(1).expect("reconstruct after reopen");
assert_eq!(rebuilt.len(), 2);
assert!(log.manifest().status == "completed");
}
#[test]
fn migrate_legacy_imports_history_and_trajectory() {
let dir = TempDir::new().expect("tempdir");
let vt = dir.path().join(".vtcode");
fs::create_dir_all(vt.join("history")).expect("mk history");
fs::create_dir_all(vt.join("logs")).expect("mk logs");
let memory = serde_json::json!({
"session_id": "session-foo",
"schema_version": 2,
"summary": "did a thing",
"grounded_facts": [{"fact": "the widget is blue"}],
});
fs::write(
vt.join("history").join("session-foo.memory.json"),
serde_json::to_string_pretty(&memory).expect("ser"),
)
.expect("write memory");
fs::write(
vt.join("logs").join("trajectory-20260101T000000Z.jsonl"),
"{\"kind\":\"llm_retry_metrics\",\"turn\":1}\n",
)
.expect("write traj");
let report = migrate_legacy(dir.path(), false).expect("migrate");
assert_eq!(report.sessions_created, 2);
assert_eq!(report.memory_imported, 1);
assert_eq!(report.trajectory_imported, 1);
let facts = query_facts(dir.path(), 10).expect("facts");
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].fact, "the widget is blue");
assert!(vt.join("history").exists());
assert!(vt.join("logs").exists());
let sessions = recent_sessions(dir.path(), 10);
assert_eq!(sessions.len(), 2);
}
#[test]
fn retention_removes_oldest_sessions() {
let dir = TempDir::new().expect("tempdir");
for i in 0..5u64 {
let log = open(dir.path(), &format!("sess-{i}"), DEFAULT_MAX_EVENTS).expect("open");
for e in &sample_turn() {
log.append(e).expect("append");
}
log.complete().expect("complete");
let mpath = sessions_root(dir.path()).join(format!("sess-{i}")).join("manifest.json");
let mut m: crate::SessionManifest =
serde_json::from_str(&fs::read_to_string(&mpath).expect("read manifest")).expect("parse");
if i < 3 {
m.updated_at = format!("2020-01-{:02}T00:00:00Z", i + 1);
fs::write(&mpath, serde_json::to_string_pretty(&m).expect("ser")).expect("write manifest");
}
}
let removed = apply_retention(dir.path(), crate::retention::RetentionPolicy { max_sessions: 4, max_age_days: 30 })
.expect("retain");
assert_eq!(removed, 3);
let remaining = recent_sessions(dir.path(), 100);
assert_eq!(remaining.len(), 2);
}
#[test]
fn retention_evicts_old_sessions_even_when_under_count_cap() {
let dir = TempDir::new().expect("tempdir");
for i in 0..3u64 {
let log = open(dir.path(), &format!("sess-{i}"), DEFAULT_MAX_EVENTS).expect("open");
for e in &sample_turn() {
log.append(e).expect("append");
}
log.complete().expect("complete");
if i == 0 {
let mpath = sessions_root(dir.path()).join("sess-0").join("manifest.json");
let mut m: crate::SessionManifest =
serde_json::from_str(&fs::read_to_string(&mpath).expect("read manifest")).expect("parse");
m.updated_at = "2020-01-01T00:00:00Z".to_string();
fs::write(&mpath, serde_json::to_string_pretty(&m).expect("ser")).expect("write manifest");
}
}
let removed = apply_retention(dir.path(), crate::retention::RetentionPolicy { max_sessions: 10, max_age_days: 30 })
.expect("retain");
assert_eq!(removed, 1);
let remaining = recent_sessions(dir.path(), 100);
assert_eq!(remaining.len(), 2);
}
#[test]
fn manifest_shortcut_skips_scan_on_reopen() {
let dir = TempDir::new().expect("tempdir");
{
let log = open(dir.path(), "sess-shortcut", DEFAULT_MAX_EVENTS).expect("open");
for e in &sample_turn() {
log.append(e).expect("append");
}
log.complete().expect("complete");
}
let log = SessionEventLog::open(dir.path(), "sess-shortcut", DEFAULT_MAX_EVENTS).expect("reopen");
assert_eq!(log.turn_count(), 1);
assert_eq!(log.manifest().status, "completed");
let rebuilt = log.reconstruct_turn(1).expect("reconstruct");
assert_eq!(rebuilt.len(), 2);
}
#[test]
fn scan_fallback_when_manifest_missing() {
let dir = TempDir::new().expect("tempdir");
let session_dir = dir.path().join(".vtcode/sessions/sess-raw");
let events_path = session_dir.join("events.jsonl");
fs::create_dir_all(&session_dir).expect("mkdir");
let events = [
VersionedThreadEvent::new(ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "t-1".to_string() })),
VersionedThreadEvent::new(ThreadEvent::TurnStarted(TurnStartedEvent::default())),
VersionedThreadEvent::new(ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
];
let lines: Vec<String> = events.iter().map(|v| serde_json::to_string(v).expect("ser")).collect();
fs::write(&events_path, lines.join("\n") + "\n").expect("write raw events");
let log = SessionEventLog::open(dir.path(), "sess-raw", DEFAULT_MAX_EVENTS).expect("open");
assert_eq!(log.turn_count(), 1);
let rebuilt = log.reconstruct_turn(1).expect("reconstruct");
assert_eq!(rebuilt.len(), 2);
}
#[test]
fn scan_skips_malformed_lifecycle_payloads() {
let dir = TempDir::new().expect("tempdir");
let session_dir = dir.path().join(".vtcode/sessions/sess-invalid");
let events_path = session_dir.join("events.jsonl");
fs::create_dir_all(&session_dir).expect("mkdir");
let valid_events = [
VersionedThreadEvent::new(ThreadEvent::TurnStarted(TurnStartedEvent::default())),
VersionedThreadEvent::new(ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
];
let mut lines = vec![
r#"{"schema_version":"0.11.0","event":{"type":"thread.started","thread_id":123}}"#.to_string(),
r#"{"schema_version":"0.11.0","event":{"type":"turn.started","token_breakdown":"invalid"}}"#.to_string(),
r#"{"schema_version":"0.11.0","event":{"type":"turn.completed","usage":{}}}"#.to_string(),
];
lines.extend(
valid_events
.iter()
.map(|event| serde_json::to_string(event).expect("serialize")),
);
fs::write(&events_path, lines.join("\n") + "\n").expect("write raw events");
let log = SessionEventLog::open(dir.path(), "sess-invalid", DEFAULT_MAX_EVENTS).expect("open");
assert_eq!(log.event_count(), 2);
assert_eq!(log.turn_count(), 1);
assert_eq!(log.reconstruct_turn(1).expect("reconstruct").len(), 2);
}