supercode-harness 0.4.11

The optional native Supercode agent and tool harness
Documentation
//! A2 acceptance: the sidecar family's home in `SessionStore` (SPEC.md A2) —
//! `sidecar_path`/`save_sidecar`/`load_sidecar`/`save_reduction_log`/
//! `load_reduction_log`, `SidecarWriter`'s append-only lifecycle, and the
//! whole-family `archive`/`delete` coupling (D1).

use std::path::{Path, PathBuf};

use supercode_harness::reduce::ReductionLog;
use supercode_harness::session::Session;
use supercode_harness::sidecar::SidecarWriter;
use supercode_harness::{ChatMessage, SessionStore};

fn fixture(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name)
}

fn temp_dir(tag: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!(
        "supercode-sidecarstore-{tag}-{}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// All five `<name>.*` family members (D1), used to assert `archive`/`delete`
/// move/remove the whole set, tolerating absent members.
fn family_present(root: &Path, name: &str) -> Vec<String> {
    let exts = [
        "jsonl",
        "meta.json",
        "sidecar.jsonl",
        "reduction.json",
        "events.jsonl",
    ];
    exts.iter()
        .filter(|ext| root.join(format!("{name}.{ext}")).exists())
        .map(|ext| ext.to_string())
        .collect()
}

#[test]
fn sidecar_store_lifecycle() {
    let dir = temp_dir("lifecycle");
    let store = SessionStore::open(&dir).unwrap();
    let name = "sess1";

    // Establish the other family members alongside the sidecar, so archive
    // and delete are exercised against the WHOLE family (D1), not just the
    // sidecar in isolation.
    store
        .save(name, "A session", "{\"role\":\"user\"}\n")
        .unwrap();
    store
        .save_reduction_log(name, &ReductionLog::default())
        .unwrap();
    // events.jsonl is CLI-owned (C8) — no writer exists yet in this wave, so
    // simulate its presence directly by the D1-specified filename.
    std::fs::write(dir.join(format!("{name}.events.jsonl")), "{}\n").unwrap();

    let session = Session::from_codex(fixture("codex_session.jsonl")).unwrap();
    let imported_count = session.messages.len();

    let sidecar_path = store.sidecar_path(name);
    assert_eq!(sidecar_path, dir.join(format!("{name}.sidecar.jsonl")));

    let mut writer = SidecarWriter::create(&sidecar_path, &session).unwrap();
    let appended = vec![
        ChatMessage::user("hello again"),
        ChatMessage::assistant("sure, on it"),
        ChatMessage::tool_result("call_9", "shell", "ok"),
    ];
    for m in &appended {
        writer.append(m).unwrap();
    }
    drop(writer);

    assert_eq!(
        family_present(&dir, name).len(),
        5,
        "all five family members should exist before archive/delete"
    );

    // load_sidecar + from_native_str -> messages = fixture + 3, metadata intact.
    let raw = store
        .load_sidecar(name)
        .unwrap()
        .expect("sidecar must exist");
    let reloaded = Session::from_native_str(&raw).unwrap();
    assert_eq!(
        reloaded.messages.len(),
        imported_count + appended.len(),
        "reloaded messages must be imported + appended"
    );
    for (i, (orig, got)) in session.messages.iter().zip(&reloaded.messages).enumerate() {
        assert_eq!(orig.role, got.role, "imported message {i} role");
        assert_eq!(orig.content, got.content, "imported message {i} content");
        assert_eq!(orig.metadata, got.metadata, "imported message {i} metadata");
    }
    for (i, (orig, got)) in appended
        .iter()
        .zip(&reloaded.messages[imported_count..])
        .enumerate()
    {
        assert_eq!(orig.role, got.role, "appended message {i} role");
        assert_eq!(orig.content, got.content, "appended message {i} content");
    }

    // archive moves the whole family; the sidecar (and reduction log) still
    // resolve afterward via the archived fallback.
    store.archive(name).unwrap();
    assert_eq!(
        family_present(&dir, name).len(),
        0,
        "nothing should remain in the active dir after archive"
    );
    let archived_dir = dir.join("archived");
    assert_eq!(
        family_present(&archived_dir, name).len(),
        5,
        "all five family members should have moved into archived/"
    );
    let raw_after_archive = store
        .load_sidecar(name)
        .unwrap()
        .expect("sidecar still resolves (archived fallback) after archive");
    assert_eq!(raw_after_archive, raw, "archived sidecar content unchanged");
    assert!(
        store.load_reduction_log(name).unwrap().is_some(),
        "reduction log still resolves after archive"
    );

    // delete removes ALL family files (directory listing asserts none remain).
    store.delete(name).unwrap();
    assert!(store.load_sidecar(name).unwrap().is_none());
    assert!(store.load_reduction_log(name).unwrap().is_none());
    assert_eq!(
        family_present(&dir, name).len(),
        0,
        "no active family member should remain after delete"
    );
    assert_eq!(
        family_present(&archived_dir, name).len(),
        0,
        "no archived family member should remain after delete"
    );

    std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn torn_tail_tolerated() {
    let dir = temp_dir("torn");
    let store = SessionStore::open(&dir).unwrap();
    let name = "torn1";

    let session = Session::from_codex(fixture("codex_session.jsonl")).unwrap();
    let sidecar_path = store.sidecar_path(name);
    let mut writer = SidecarWriter::create(&sidecar_path, &session).unwrap();
    writer
        .append(&ChatMessage::user("first appended turn"))
        .unwrap();
    writer
        .append(&ChatMessage::assistant("second appended turn"))
        .unwrap();
    writer
        .append(&ChatMessage::assistant(
            "third appended turn, about to be torn",
        ))
        .unwrap();
    drop(writer);

    // Simulate a crash mid-append: truncate well into the final line (not
    // just its trailing newline), leaving invalid JSON on the last line.
    let mut bytes = std::fs::read(&sidecar_path).unwrap();
    let cut = bytes.len().saturating_sub(20);
    bytes.truncate(cut);
    std::fs::write(&sidecar_path, &bytes).unwrap();

    let raw = store.load_sidecar(name).unwrap().unwrap();
    let reloaded = Session::from_native_str(&raw).expect("torn tail must not error");

    // The last FULL record survives...
    assert!(reloaded
        .messages
        .iter()
        .any(|m| m.content.as_deref() == Some("first appended turn")));
    assert!(reloaded
        .messages
        .iter()
        .any(|m| m.content.as_deref() == Some("second appended turn")));
    // ...and the torn trailing record does not silently produce garbage
    // content (it's simply dropped, the same tolerance the per-source
    // parsers already give corrupt lines).
    assert!(!reloaded
        .messages
        .iter()
        .any(|m| m.content.as_deref() == Some("third appended turn, about to be torn")));

    std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn invalid_name_never_produces_sidecar_or_reduction_files() {
    let dir = temp_dir("badname");
    let store = SessionStore::open(&dir).unwrap();

    for bad in ["../escape", "..", "a/b", "/abs", "", "  ", ".", "x\0y"] {
        assert!(
            store.save_sidecar(bad, "{}").is_err(),
            "save_sidecar should reject `{bad}`"
        );
        assert!(
            store.load_sidecar(bad).is_err(),
            "load_sidecar should reject `{bad}`"
        );
        assert!(
            store
                .save_reduction_log(bad, &ReductionLog::default())
                .is_err(),
            "save_reduction_log should reject `{bad}`"
        );
        assert!(
            store.load_reduction_log(bad).is_err(),
            "load_reduction_log should reject `{bad}`"
        );
    }
    // Nothing escaped the root: no sidecar file appears next to it.
    assert!(!dir.parent().unwrap().join("escape.sidecar.jsonl").exists());

    std::fs::remove_dir_all(&dir).ok();
}