supercode-harness 0.4.13

The optional native Supercode agent and tool harness
Documentation
//! Acceptance test closing a coverage gap in TR-3 (`.volter/tracker/markdown/TR-3.md`)
//! flagged by adversarial review: every existing `FileReadDiffed` test
//! (`reduce_projection.rs`) mints AND inverts against the SAME in-memory
//! `Session`/message vector (`project`/`project_messages` called directly on
//! a hand-built slice). None of them drive `FileReadDiffed` through the path
//! the control guarantor actually re-runs: live `Agent` -> recorder(disk
//! sidecar) -> **reload from disk** -> offline `verify_log`/`invert`.
//!
//! THE GAP: TR-12's regression (`tr12_cap_supersession.rs`) proved that a
//! hash minted from an in-memory copy of `history` can silently diverge from
//! what a reloaded sidecar recomputes, once `cap_tool_output` mutates the
//! bytes behind the mint site. `FileReadDiffed`'s mint site (`reduce.rs`'s
//! A8/TR-3 pass, run from `Agent::build_request_messages` over
//! `self.history[1..]`) sits in exactly the same position in the pipeline —
//! nothing in the existing TR-3 suite would have caught an equivalent
//! divergence for `FileReadDiffed` specifically, because none of those tests
//! ever touch a recorder, a sidecar file, or a disk reload. This test fills
//! that gap, reusing the exact harness idiom `tr2_dedup_guarantor.rs`
//! established (`Agent::with_parts` + a scripted `Provider`,
//! `SidecarWriter`/`SessionStore`, offline reload via
//! `Session::from_sidecar_str`, `verify_log` + `invert`) — with a REAL
//! `read_file` built-in tool (`ToolRegistry::with_builtins`) reading a REAL
//! file on disk, rather than a synthetic always-same-payload tool, since
//! `FileReadDiffed`'s candidacy is keyed on `read_file`'s own tool-call shape
//! (`detect_reads`, reduce.rs).
//!
//! dev/01 (guarantor-path regression): a live agent reads a file, the file is
//! edited on disk, the agent re-reads it — minting a `FileReadDiffed`
//! reduction (A7 disabled via a maxed-out trigger, and
//! `protect_last_n_tool_results: 0`, so the re-read is not pre-empted by
//! either A7 or the "keep the last N" protection, mirroring
//! `reduce_projection.rs::tr3_policy`). The sidecar and reduction log are
//! persisted and then reloaded from disk via `SessionStore` (the exact
//! primitive `cli/main.rs`'s `show-reductions`/`convert`/`inspect` use) —
//! never the live `Agent`/in-memory `Session`. `verify_log` and `invert` both
//! run OFFLINE against that reloaded state and must pass clean, with
//! `invert` restoring the full verbatim re-read byte-exact.

use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use supercode_harness::reduce::{
    invert, project_messages, verify_log, ReductionKind, ReductionPolicy,
};
use supercode_harness::session::Session;
use supercode_harness::sidecar::SidecarWriter;
use supercode_harness::store::SessionStore;
use supercode_harness::{
    Agent, ChatMessage, ChatRequest, Config, FunctionCall, Provider, Role, ToolCall, Usage,
};

fn temp_dir(tag: &str) -> PathBuf {
    static N: AtomicUsize = AtomicUsize::new(0);
    let dir = std::env::temp_dir().join(format!(
        "supercode-tr3-guarantor-{tag}-{}-{}",
        std::process::id(),
        N.fetch_add(1, Ordering::SeqCst)
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Deterministic multi-line file content: `n` lines of `"line NNNN"`
/// (mirrors `reduce_projection.rs::n_line_file`, duplicated here since
/// integration test binaries share no code).
fn n_line_file(n: usize) -> String {
    use std::fmt::Write;
    let mut out = String::with_capacity(n * 10);
    for i in 0..n {
        writeln!(out, "line {i:04}").unwrap();
    }
    out
}

/// `content` with lines `[from, from+len)` (0-indexed) replaced by
/// `"CHANGED <i>"` — a small, contiguous, localized edit that leaves the
/// line count unchanged (mirrors `reduce_projection.rs::edit_lines`).
fn edit_lines(content: &str, from: usize, len: usize) -> String {
    let mut out = String::with_capacity(content.len());
    for (i, line) in content.lines().enumerate() {
        if i >= from && i < from + len {
            out.push_str(&format!("CHANGED {i}"));
        } else {
            out.push_str(line);
        }
        out.push('\n');
    }
    out
}

/// Build an assistant message issuing a single `read_file` tool call for
/// `path`, with call id `id` (mirrors `reduce_projection.rs::read_call`).
fn read_call(id: &str, path: &std::path::Path) -> ChatMessage {
    ChatMessage {
        role: Role::Assistant,
        content: None,
        content_parts: None,
        tool_calls: Some(vec![ToolCall {
            id: id.to_string(),
            kind: "function".to_string(),
            function: FunctionCall {
                name: "read_file".to_string(),
                arguments: serde_json::json!({ "path": path.to_string_lossy() }).to_string(),
            },
        }]),
        tool_call_id: None,
        name: None,
        metadata: Default::default(),
    }
}

/// Scripts exactly the 4-call sequence a live agent takes across two
/// `Agent::send` calls to read the same file twice with an edit in between:
/// call 0 issues the first `read_file`, call 1 replies plain text (ending
/// the first `send`); call 2 issues the second `read_file` (the re-read),
/// call 3 replies plain text (ending the second `send`). Every call after
/// that also replies plain text, so a stray extra provider call never panics
/// the test.
struct ReadEditReReadThenPlain {
    calls: AtomicUsize,
    path: PathBuf,
}
#[async_trait]
impl Provider for ReadEditReReadThenPlain {
    async fn complete(
        &self,
        _req: &ChatRequest,
        _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
    ) -> supercode_harness::Result<(ChatMessage, Usage)> {
        let n = self.calls.fetch_add(1, Ordering::SeqCst);
        match n {
            0 => Ok((read_call("c0", &self.path), Usage::default())),
            2 => Ok((read_call("c1", &self.path), Usage::default())),
            _ => Ok((
                ChatMessage::assistant(format!("done {n}")),
                Usage::default(),
            )),
        }
    }
}

/// A policy mirroring `reduce_projection.rs::tr3_policy`: A7 disabled
/// entirely (`tool_output_trigger_bytes: usize::MAX`) and no protected tail,
/// so a read-tool result is never claimed by `ToolOutputTruncated` or the
/// "keep the last N" guard before TR-3 gets a chance to diff it.
/// `diff_rereads`/`diff_max_percent` are left at their defaults (`true`/`50`).
fn tr3_policy() -> ReductionPolicy {
    ReductionPolicy {
        tool_output_trigger_bytes: usize::MAX,
        protect_last_n_tool_results: 0,
        ..ReductionPolicy::default()
    }
}

/// dev/01 (guarantor-path regression, TR-3 coverage-gap closure): a live
/// agent run with a recorder + `ReductionPolicy` both active reads a real
/// file, the file is edited on disk, the agent re-reads it — minting a
/// `FileReadDiffed` reduction; `verify_log` AND `invert` then run OFFLINE
/// against the sidecar and reduction log reloaded from disk (never the live
/// `Agent`/in-memory `Session`). Both must pass clean, and `invert` must
/// restore the full verbatim re-read byte-exact.
#[tokio::test]
async fn file_read_diffed_survives_live_agent_disk_reload_offline_verify_and_invert() {
    let dir = temp_dir("dev01");
    let file_path = dir.join("watched.rs");
    let base_content = n_line_file(300);
    std::fs::write(&file_path, &base_content).unwrap();

    let store_dir = dir.join("store");
    let store = SessionStore::open(&store_dir).unwrap();
    let name = "tr3-dev01";
    let sidecar_path = store.sidecar_path(name);

    let config = Config::builder().cwd(dir.clone()).build();
    let mut agent = Agent::with_parts(
        config,
        Box::new(ReadEditReReadThenPlain {
            calls: AtomicUsize::new(0),
            path: file_path.clone(),
        }),
        supercode_harness::tools::ToolRegistry::with_builtins(),
    );

    let empty_session = Session::from_claude_code_str("").unwrap();
    let writer = SidecarWriter::create(&sidecar_path, &empty_session).unwrap();
    agent.set_recorder(writer);
    let policy = tr3_policy();
    agent.set_reduction_policy(policy.clone());

    // First send: reads the file once (call 0), then a plain reply (call 1)
    // ends the turn.
    let reply1 = agent.send("please read the file").await.unwrap();
    assert!(reply1.starts_with("done"), "unexpected reply: {reply1}");

    // No FileReadDiffed yet -- this is the first (and only, so far) read of
    // this path, nothing to diff against.
    let log_after_first = agent.reduction_log().clone();
    assert!(
        !log_after_first
            .reductions
            .iter()
            .any(|r| matches!(r.kind, ReductionKind::FileReadDiffed { .. })),
        "the first-ever read of a path must never mint FileReadDiffed: {:?}",
        log_after_first.reductions
    );
    assert_eq!(
        log_after_first.read_log.len(),
        1,
        "the first read must have been logged"
    );

    // Edit the file on disk (small, localized change) before the re-read.
    let new_content = edit_lines(&base_content, 150, 3);
    assert_ne!(base_content, new_content);
    std::fs::write(&file_path, &new_content).unwrap();

    // Second send: re-reads the (now-changed) file (call 2), then a plain
    // reply (call 3) ends the turn -- this must mint FileReadDiffed.
    let reply2 = agent.send("read it again").await.unwrap();
    assert!(reply2.starts_with("done"), "unexpected reply: {reply2}");

    let log_live = agent.reduction_log().clone();
    let diffs: Vec<_> = log_live
        .reductions
        .iter()
        .filter(|r| matches!(r.kind, ReductionKind::FileReadDiffed { .. }))
        .collect();
    assert_eq!(
        diffs.len(),
        1,
        "the edited re-read must mint exactly one FileReadDiffed: {:?}",
        log_live.reductions
    );
    let diff_idx = diffs[0].ptr.addr.index;

    // Persist the reduction log the way the CLI does (`sessions
    // show-reductions`/`convert`/`inspect` all call `store.save_reduction_log`
    // via the resume/chat path).
    store.save_reduction_log(name, &log_live).unwrap();

    // --- OFFLINE from here: fresh reads from disk, no live Agent/Session ---
    let sidecar_jsonl = store
        .load_sidecar(name)
        .unwrap()
        .expect("sidecar must exist on disk");
    let sidecar = Session::from_sidecar_str(&sidecar_jsonl).unwrap();
    let log_reloaded = store
        .load_reduction_log(name)
        .unwrap()
        .expect("reduction log must exist on disk");

    // Confirm the reloaded-from-disk log still carries the FileReadDiffed
    // reduction before verifying/inverting against it.
    assert!(
        log_reloaded
            .reductions
            .iter()
            .any(|r| matches!(r.kind, ReductionKind::FileReadDiffed { .. })),
        "the reloaded-from-disk log must still carry the FileReadDiffed reduction: {:?}",
        log_reloaded.reductions
    );

    // The exact primitive `cli/main.rs`'s `show-reductions`/`convert`/
    // `inspect` all call before doing anything else with a reduced session.
    verify_log(&log_reloaded, &sidecar)
        .expect("verify_log must pass clean against the reloaded-from-disk sidecar");

    let (final_view, reprojected_log) = project_messages(&sidecar.messages, &policy, &log_reloaded);
    assert_eq!(
        reprojected_log, log_reloaded,
        "re-projecting from the reloaded sidecar with its own log must not invent new reductions"
    );

    let inverted = invert(&final_view, &log_reloaded, &sidecar)
        .expect("invert must pass clean against the reloaded-from-disk sidecar");
    assert_eq!(inverted.len(), sidecar.messages.len());
    for (a, b) in inverted.iter().zip(&sidecar.messages) {
        assert_eq!(a.role, b.role);
        assert_eq!(a.content, b.content);
    }

    // Byte-exact restore of the diffed re-read, from the disk-reloaded
    // sidecar.
    assert_eq!(
        inverted[diff_idx].content.as_deref(),
        Some(new_content.as_str()),
        "invert must restore the full verbatim re-read byte-exact from disk"
    );

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