supercode-core 0.2.1

A lightweight, fully-customizable AI coding agent SDK in Rust. Talks to any model via OpenRouter or any OpenAI-compatible endpoint.
Documentation
//! Acceptance test closing the same coverage-gap class in TR-4
//! (`.volter/tracker/markdown/TR-4.md`) that `tr2_dedup_guarantor.rs` and
//! `tr3_diff_guarantor.rs` already closed for `DuplicateOutput` and
//! `FileReadDiffed`: every existing `OutputNormalized` test
//! (`reduce_normalize.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 `OutputNormalized` 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. `OutputNormalized`'s mint site (`reduce.rs`'s
//! T30/TR-4 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-4 suite would have caught an equivalent
//! divergence for `OutputNormalized` 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`/
//! `tr3_diff_guarantor.rs` established (`Agent::with_parts` + a scripted
//! `Provider`, a fixed-payload tool, `SidecarWriter`/`SessionStore`, offline
//! reload via `Session::from_sidecar_str`, `verify_log` + `invert`) — with a
//! tool named `"bash"` (one of [`supercode::reduce::normalize::NORMALIZE_TOOLS`])
//! so the T30/TR-4 candidate rule (keyed on tool IDENTITY, `detect_normalize_candidates`)
//! actually fires, exactly like `tr3_diff_guarantor.rs` used a real
//! `read_file` call so TR-3's own path-keyed candidate rule would fire.
//!
//! dev/01-dev/02 (guarantor-path regression): a live agent runs a `"bash"`
//! tool call whose raw output is a synthetic ANSI/CR-redraw-heavy progress
//! bar (comfortably above `terminal_output_min_savings`'s floor once
//! normalized) — minting an `OutputNormalized` reduction. 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 RAW captured bytes byte-exact (never the normalized/rendered
//! text) — the "lossy view over lossless sidecar" contract (SPEC.md B10).

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

use async_trait::async_trait;
use supercode::reduce::{invert, project_messages, verify_log, ReductionKind, ReductionPolicy};
use supercode::session::Session;
use supercode::sidecar::SidecarWriter;
use supercode::store::SessionStore;
use supercode::{
    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-tr4-guarantor-{tag}-{}-{}",
        std::process::id(),
        N.fetch_add(1, Ordering::SeqCst)
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// A synthetic `cargo build`-style progress bar: many `\r`-overwritten
/// frames wrapped in SGR color codes, ending with a final frame and a plain
/// newline. Deterministic and self-contained (no real terminal capture
/// needed) — mirrors the fixture style documented in TR-4.md's dev/01, just
/// generated in-line so this test has no file-fixture dependency.
fn ansi_progress_bar(frames: usize) -> String {
    let mut raw = String::new();
    for i in 0..frames {
        let pct = (i * 100) / frames.max(1);
        raw.push_str(&format!(
            "\x1b[2K\r\x1b[32mDownloading widget-crate: {pct:>3}%\x1b[0m"
        ));
    }
    raw.push_str("\x1b[2K\r\x1b[32mDownloading widget-crate: 100%\x1b[0m\n");
    raw.push_str("Done.\n");
    raw
}

/// A tool named `"bash"` (one of
/// [`supercode::reduce::normalize::NORMALIZE_TOOLS`]) that always returns the
/// SAME fixed raw ANSI/CR-heavy payload — the T30/TR-4 normalize candidate,
/// analogous to `tr2_dedup_guarantor.rs`'s `DupTool`.
struct FixedBashTool(String);
#[async_trait]
impl supercode::tools::Tool for FixedBashTool {
    fn name(&self) -> &str {
        "bash"
    }
    fn description(&self) -> &str {
        "x"
    }
    fn parameters(&self) -> serde_json::Value {
        serde_json::json!({"type": "object"})
    }
    async fn execute(
        &self,
        _a: serde_json::Value,
        _c: &supercode::tools::ToolContext,
    ) -> supercode::Result<String> {
        Ok(self.0.clone())
    }
}

fn bash_call_msg(id: &str) -> 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: "bash".to_string(),
                arguments: serde_json::json!({"command": "cargo build"}).to_string(),
            },
        }]),
        tool_call_id: None,
        name: None,
        metadata: Default::default(),
    }
}

/// One `bash` tool call, then a plain-text reply ends the turn (mirrors
/// `tr3_diff_guarantor.rs`'s `ReadEditReReadThenPlain` shape, simplified to a
/// single call since T30/TR-4 needs no second turn to fire).
struct BashThenPlain {
    calls: AtomicUsize,
}
#[async_trait]
impl Provider for BashThenPlain {
    async fn complete(
        &self,
        _req: &ChatRequest,
        _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
    ) -> supercode::Result<(ChatMessage, Usage)> {
        let n = self.calls.fetch_add(1, Ordering::SeqCst);
        match n {
            0 => Ok((bash_call_msg("c0"), Usage::default())),
            _ => Ok((
                ChatMessage::assistant(format!("done {n}")),
                Usage::default(),
            )),
        }
    }
}

/// dev/01-dev/02 (guarantor-path regression, TR-4 coverage-gap closure): a
/// live agent run with a recorder + `ReductionPolicy` both active produces an
/// `OutputNormalized` reduction from a real `"bash"`-named tool call;
/// `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 RAW
/// captured bytes byte-exact — never the normalized text.
#[tokio::test]
async fn output_normalized_survives_live_agent_disk_reload_offline_verify_and_invert() {
    let dir = temp_dir("dev01-dev02");
    let store_dir = dir.join("store");
    let store = SessionStore::open(&store_dir).unwrap();
    let name = "tr4-dev01";
    let sidecar_path = store.sidecar_path(name);

    // 100 CR-overwritten, SGR-colored progress frames: comfortably above
    // `terminal_output_min_savings`'s default 128B floor once collapsed down
    // to its final rendered line, and comfortably below the default A7
    // `tool_output_trigger_bytes` (8,192B) so this test's mint is
    // unambiguously T30/TR-4's own pass, not A7's.
    let raw = ansi_progress_bar(100);
    assert!(
        raw.len() < ReductionPolicy::default().tool_output_trigger_bytes,
        "fixture must stay under the A7 trigger so only OutputNormalized can fire: {}B",
        raw.len()
    );

    let config = Config::builder().cwd(dir.clone()).build();
    let mut reg = supercode::tools::ToolRegistry::new();
    reg.register(FixedBashTool(raw.clone()));
    let mut agent = Agent::with_parts(
        config,
        Box::new(BashThenPlain {
            calls: AtomicUsize::new(0),
        }),
        reg,
    );

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

    let reply = agent.send("build the widget crate").await.unwrap();
    assert!(reply.starts_with("done"), "unexpected reply: {reply}");

    let log_live = agent.reduction_log().clone();
    let normalized: Vec<_> = log_live
        .reductions
        .iter()
        .filter(|r| matches!(r.kind, ReductionKind::OutputNormalized { .. }))
        .collect();
    assert_eq!(
        normalized.len(),
        1,
        "the noisy bash output must mint exactly one OutputNormalized reduction: {:?}",
        log_live.reductions
    );
    let norm_idx = normalized[0].ptr.addr.index;
    let (orig_bytes, norm_bytes) = match normalized[0].kind {
        ReductionKind::OutputNormalized {
            original_bytes,
            normalized_bytes,
        } => (original_bytes, normalized_bytes),
        _ => unreachable!(),
    };
    assert_eq!(orig_bytes, raw.len());
    assert!(
        orig_bytes.saturating_sub(norm_bytes) >= policy.terminal_output_min_savings,
        "the mint must have cleared the savings floor: {orig_bytes}B -> {norm_bytes}B"
    );

    // Never claimed by any other pass this run.
    assert_eq!(
        log_live
            .reductions
            .iter()
            .filter(|r| r.ptr.addr.index == norm_idx)
            .count(),
        1,
        "the normalized message must carry exactly one reduction, not double-claimed"
    );

    // 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 OutputNormalized
    // reduction before verifying/inverting against it.
    assert!(
        log_reloaded
            .reductions
            .iter()
            .any(|r| matches!(r.kind, ReductionKind::OutputNormalized { .. })),
        "the reloaded-from-disk log must still carry the OutputNormalized reduction: {:?}",
        log_reloaded.reductions
    );

    // The sidecar itself must hold the RAW (unnormalized) bytes at this
    // index -- A3's "sidecar keeps everything" contract, independent of
    // whatever the projected view showed.
    assert_eq!(
        sidecar.messages[norm_idx].content.as_deref(),
        Some(raw.as_str()),
        "the sidecar must retain the RAW captured bytes, never the normalized view text"
    );

    // 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 RAW captured bytes -- never the
    // normalized/rendered text -- from the disk-reloaded sidecar.
    assert_eq!(
        inverted[norm_idx].content.as_deref(),
        Some(raw.as_str()),
        "invert must restore the RAW captured bytes byte-exact from disk, not the normalized view"
    );

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