supercode-harness 0.4.10

The optional native Supercode agent and tool harness
Documentation
//! P5-5 (COMPOSABLE-HARNESS-DESIGN.md §2 module 21 `session.tree`; §2.1 D-6;
//! §2.2 C7; §5.2 P5 row 5): end-to-end proofs for the native in-place
//! session tree — tying `crate::session_tree::SessionTree` together with
//! `crate::session::Session` (the linear-projection bridge) and
//! `crate::store::SessionStore` (the `.tree.json` sidecar), the seam this
//! module actually lives at.
//!
//! Four load-bearing proofs, one test group each:
//! 1. **Default-off / linear-session byte-identity**: a `Session` that never
//!    touches a `SessionTree` behaves exactly as before (no `.tree.json`
//!    ever written; `SessionStore::save`/`load` unchanged).
//! 2. **Linear-projection exactness**: `Session::to_session_tree` →
//!    `SessionTree::linear_projection` → `Session::apply_session_tree`
//!    round-trips a linear session's messages exactly.
//! 3. **Lossless rewind**: rewound-past data is recoverable through the
//!    store after a save/load cycle (not just in memory).
//! 4. **C7 linear export**: a branched tree's active path + off-path branch
//!    summaries survive a save/load cycle, with the off-path branch's full
//!    data still recoverable from the very same sidecar.

use supercode_harness::session::Session;
use supercode_harness::session_tree::{BranchSummarizer, MAIN_BRANCH};
use supercode_harness::store::SessionStore;
use supercode_harness::ChatMessage;

fn temp_store() -> (SessionStore, std::path::PathBuf) {
    // `SystemTime` has coarser-than-nanosecond resolution on some platforms.
    // These tests run in parallel inside one process, so pid+reported nanos
    // can collide and make independent tests concurrently truncate/read the
    // same `.tree.json` file. A process-local nonce makes isolation
    // deterministic regardless of clock resolution or thread scheduling.
    static NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let nonce = NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let dir = std::env::temp_dir().join(format!(
        "supercode-session-tree-native-{}-{}-{nonce}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    (SessionStore::open(&dir).unwrap(), dir)
}

fn content_of(m: &ChatMessage) -> &str {
    m.content.as_deref().unwrap_or("")
}

#[test]
fn parallel_temp_stores_are_always_isolated() {
    let barrier = std::sync::Arc::new(std::sync::Barrier::new(32));
    let handles: Vec<_> = (0..32)
        .map(|_| {
            let barrier = barrier.clone();
            std::thread::spawn(move || {
                barrier.wait();
                let (_store, path) = temp_store();
                path
            })
        })
        .collect();
    let paths: std::collections::HashSet<_> =
        handles.into_iter().map(|h| h.join().unwrap()).collect();
    assert_eq!(paths.len(), 32);
    for path in paths {
        let _ = std::fs::remove_dir_all(path);
    }
}

// ============================================================================
// 1. Default-off byte-identity: a linear session's SAVE/LOAD path is
//    completely untouched by this module's existence.
// ============================================================================

#[test]
fn a_session_that_never_touches_the_tree_writes_no_tree_sidecar() {
    let (store, tmp) = temp_store();
    let mut session = Session::from_claude_code_str("").unwrap();
    session.messages.push(ChatMessage::user("hello"));
    session.messages.push(ChatMessage::assistant("hi"));

    let jsonl = session.to_native_jsonl();
    store.save("sess", "t", &jsonl).unwrap();

    // The plain save/load path is exactly what it always was.
    assert_eq!(store.load("sess").unwrap(), jsonl);
    // No `.tree.json` sidecar exists — this module's storage is opt-in,
    // never implicit.
    assert!(store.load_tree("sess").unwrap().is_none());
    assert!(!tmp.join("sess.tree.json").exists());

    let _ = std::fs::remove_dir_all(&tmp);
}

// ============================================================================
// 2. Linear-projection exactness: the Session <-> SessionTree bridge is a
//    lossless round trip for the degenerate (never-branched) case.
// ============================================================================

#[test]
fn session_to_tree_and_back_preserves_the_message_sequence_exactly() {
    let mut session = Session::from_claude_code_str("").unwrap();
    session.messages.push(ChatMessage::user("turn 0"));
    session.messages.push(ChatMessage::assistant("turn 1"));
    session.messages.push(ChatMessage::user("turn 2"));
    let original = session.messages.clone();

    let tree = session.to_session_tree(1_700_000_000_000);
    assert!(!tree.has_branches());

    // A round-tripped clone starts identical to the original.
    let mut round_tripped = session.clone();
    round_tripped.apply_session_tree(&tree).unwrap();
    assert_eq!(round_tripped.messages.len(), original.len());
    for (a, b) in round_tripped.messages.iter().zip(original.iter()) {
        assert_eq!(content_of(a), content_of(b));
        assert_eq!(a.role, b.role);
    }
}

/// The linear projection is deterministic AND matches the active path
/// exactly even after the tree has branched: a consumer applying the tree
/// back onto a `Session` sees precisely the active branch, never a mix.
#[test]
fn linear_projection_after_branching_matches_only_the_active_path() {
    let mut session = Session::from_claude_code_str("").unwrap();
    session.messages.push(ChatMessage::user("turn 0"));
    session.messages.push(ChatMessage::assistant("turn 1"));
    let mut tree = session.to_session_tree(1);

    // Fork at the root and continue down the new branch.
    tree.branch("n0", Some("alt".to_string()), 2).unwrap();
    tree.append_message(ChatMessage::user("alt turn 1"), 3);

    session.apply_session_tree(&tree).unwrap();
    assert_eq!(session.messages.len(), 2);
    assert_eq!(content_of(&session.messages[1]), "alt turn 1");

    // Computing the projection again is deterministic (same input, same
    // output) — not a one-shot mutation artifact.
    let again = tree.linear_projection().unwrap();
    assert_eq!(again.len(), session.messages.len());
    for (a, b) in again.iter().zip(session.messages.iter()) {
        assert_eq!(content_of(a), content_of(b));
    }
}

// ============================================================================
// 2b. F2 (MEDIUM, ported from the Fable-5 adversarial review): a corrupt
//     tree must fail CLOSED — `SessionTree::linear_projection`/
//     `Session::apply_session_tree` must ERROR on a structurally-corrupt
//     tree, never silently collapse it to an empty transcript. Before the
//     fix, `linear_projection()` did `.unwrap_or_default()` over the checked
//     `linear_projection_of`, and `apply_session_tree` then unconditionally
//     assigned that (possibly-empty-on-error) result to `Session::messages`
//     — so a corrupt-but-valid-JSON `.tree.json` sidecar would silently WIPE
//     a session's messages instead of surfacing the corruption.
// ============================================================================

#[test]
fn attack_apply_session_tree_errors_instead_of_silently_wiping_messages_on_cycle() {
    let mut session = Session::from_claude_code_str("").unwrap();
    session.messages.push(ChatMessage::user("precious 0"));
    session.messages.push(ChatMessage::assistant("precious 1"));
    let mut tree = session.to_session_tree(1);
    // Simulate a corrupted/hand-edited .tree.json: 2-cycle. Valid JSON, so
    // load_tree would accept it.
    tree.nodes.get_mut("n0").unwrap().parent = Some("n1".to_string());
    // The checked API errors...
    assert!(tree.linear_projection_of(MAIN_BRANCH).is_err());
    // ...and now the convenience API propagates that error too, rather than
    // masking it to an empty Vec.
    assert!(tree.linear_projection().is_err());
    // apply_session_tree must therefore ALSO error, and leave the session's
    // precious messages untouched.
    let err = session.apply_session_tree(&tree).unwrap_err();
    assert!(err.to_string().contains("cycle"));
    assert_eq!(session.messages.len(), 2);
    assert_eq!(content_of(&session.messages[0]), "precious 0");
    assert_eq!(content_of(&session.messages[1]), "precious 1");
}

#[test]
fn attack_dangling_leaf_errors_instead_of_masking_to_empty() {
    let mut tree =
        supercode_harness::session_tree::SessionTree::from_linear(&[ChatMessage::user("x")], 1);
    tree.branches.get_mut(MAIN_BRANCH).unwrap().leaf = Some("ghost".to_string());
    assert!(tree.linear_projection_of(MAIN_BRANCH).is_err()); // checked: error
    assert!(tree.linear_projection().is_err()); // no longer masked
}

#[test]
fn attack_active_branch_pointing_nowhere_errors_instead_of_masking_to_empty() {
    let mut tree =
        supercode_harness::session_tree::SessionTree::from_linear(&[ChatMessage::user("x")], 1);
    tree.active_branch = "no-such-branch".to_string();
    assert!(tree.linear_projection().is_err()); // no longer masked
}

// ============================================================================
// 3. Lossless rewind, proven THROUGH THE STORE (not just in memory): after a
//    save/load cycle, the rewound-past data is still fully recoverable.
// ============================================================================

#[test]
fn rewound_data_is_recoverable_after_a_save_load_cycle() {
    let (store, tmp) = temp_store();
    let mut session = Session::from_claude_code_str("").unwrap();
    for i in 0..4 {
        session
            .messages
            .push(ChatMessage::user(format!("turn {i}")));
    }
    store.save("sess", "t", &session.to_native_jsonl()).unwrap();

    let mut tree = session.to_session_tree(1_700_000_000_000);
    let old_leaf = tree.branches[MAIN_BRANCH].leaf.clone().unwrap();
    let preserved = tree
        .rewind("n1", 1_700_000_001_000)
        .unwrap()
        .expect("moved the pointer, so the old path is preserved");
    store.save_tree("sess", &tree).unwrap();

    // Reload from disk — a fresh in-memory value, not the same object.
    let loaded = store.load_tree("sess").unwrap().expect("just saved");
    assert_eq!(loaded.branches[&preserved].leaf, Some(old_leaf));
    let recovered = loaded.linear_projection_of(&preserved).unwrap();
    assert_eq!(recovered.len(), 4);
    assert_eq!(content_of(&recovered[3]), "turn 3");

    // The active (rewound) path, also reloaded from disk, is the shorter
    // prefix — proving BOTH the rewind AND the preservation persisted.
    assert_eq!(loaded.linear_projection().unwrap().len(), 2);

    let _ = std::fs::remove_dir_all(&tmp);
}

// ============================================================================
// 4. C7: exporting a branched tree to a linear target splices the active
//    path and branch-summarizes the off-path branches into the SAME
//    sidecar — nothing dropped, everything still recoverable.
// ============================================================================

struct StubSummarizer;
impl BranchSummarizer for StubSummarizer {
    fn summarize(&self, branch_text: &str) -> supercode_interchange::Result<String> {
        Ok(format!(
            "digest of {} char(s)",
            branch_text.trim_end().len()
        ))
    }
    fn model_id(&self) -> &str {
        "test-small-model"
    }
}

#[test]
fn c7_linear_export_splices_active_path_and_recovers_off_path_branch_from_the_sidecar() {
    let (store, tmp) = temp_store();
    let mut session = Session::from_claude_code_str("").unwrap();
    session.messages.push(ChatMessage::user("root turn"));
    store.save("sess", "t", &session.to_native_jsonl()).unwrap();

    let mut tree = session.to_session_tree(1);
    // Branch off, and continue BOTH paths a little.
    tree.branch("n0", Some("side-quest".to_string()), 2)
        .unwrap();
    tree.append_message(ChatMessage::user("side turn"), 3);
    tree.summarize_branch_with("side-quest", &StubSummarizer, 4)
        .unwrap();
    tree.switch_branch(MAIN_BRANCH).unwrap();
    tree.append_message(ChatMessage::assistant("main turn"), 5);

    // Simulate a CX-style linear export: splice + persist the full tree
    // (with its now-attached branch summary) to the sidecar, and write ONLY
    // the active path to a (simulated) linear target file.
    let (linear_export, summaries) = tree.splice_for_linear_export().unwrap();
    store.save_tree("sess", &tree).unwrap();

    // The "linear target file" only ever sees the active path.
    assert_eq!(linear_export.len(), 2);
    assert_eq!(content_of(&linear_export[0]), "root turn");
    assert_eq!(content_of(&linear_export[1]), "main turn");

    // Exactly one off-path branch was summarized, with a model-generated
    // (not stub-fallback) digest.
    assert_eq!(summaries.len(), 1);
    assert_eq!(summaries[0].branch, "side-quest");
    assert_eq!(summaries[0].model_id.as_deref(), Some("test-small-model"));

    // Recoverability: reload the sidecar from disk and pull the FULL
    // off-path branch back out — nothing was dropped by the export.
    let loaded = store.load_tree("sess").unwrap().expect("saved above");
    let recovered = loaded.linear_projection_of("side-quest").unwrap();
    assert_eq!(recovered.len(), 2);
    assert_eq!(content_of(&recovered[1]), "side turn");
    assert!(loaded.branches["side-quest"]
        .summary
        .as_ref()
        .unwrap()
        .summary
        .contains("digest of"));

    let _ = std::fs::remove_dir_all(&tmp);
}