aether-evals 0.5.0

Dockerized eval harness for Aether AI agents
Documentation
use super::diff::GitDiff;
use super::workspace::{Workspace, new_temp_dir};
use crate::WorkspaceError;
use crate::git_repo::GitRepo;
use schemars::JsonSchema;
use std::{
    ffi::OsString,
    path::{Path, PathBuf},
};
use tempfile::TempDir;

#[derive(Debug, Clone, serde::Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GitSpec {
    pub source: GitSource,
    pub start_commit: String,
    pub gold_commit: String,
    #[serde(default)]
    pub subdir: Option<PathBuf>,
    #[serde(default = "default_branch")]
    pub branch: String,
    #[serde(default)]
    pub diff_exclude: Vec<String>,
}

#[derive(Debug, Clone, serde::Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum GitSource {
    Url(String),
    Bundle(PathBuf),
}

pub struct GitFixture {
    repo: GitRepo,
    spec: GitSpec,
    reference_diff: GitDiff,
    _repo_dir: TempDir,
}

impl GitSpec {
    pub fn new(source: GitSource, start_commit: impl Into<String>, gold_commit: impl Into<String>) -> Self {
        Self {
            source,
            start_commit: start_commit.into(),
            gold_commit: gold_commit.into(),
            subdir: None,
            branch: default_branch(),
            diff_exclude: Vec::new(),
        }
    }
}

impl GitFixture {
    pub fn fetch(spec: GitSpec) -> Result<Self, WorkspaceError> {
        let GitSpec { source, start_commit, gold_commit, diff_exclude, .. } = &spec;
        let remote = source.remote()?;
        tracing::debug!("Fetching {:?} at commits {} and {}", remote, start_commit, gold_commit);

        let repo_dir = new_temp_dir()?;
        let repo = GitRepo::init(repo_dir.path())?;
        repo.fetch(&remote, &[start_commit, gold_commit])?;
        repo.update_ref(&format!("refs/heads/{EVAL_START_REF}"), start_commit)?;
        repo.update_ref(&format!("refs/heads/{EVAL_GOLD_REF}"), gold_commit)?;
        let reference_diff = GitDiff::new(repo.diff(start_commit, gold_commit, diff_exclude)?);

        Ok(Self { repo, spec, reference_diff, _repo_dir: repo_dir })
    }

    pub fn reference_diff(&self) -> &GitDiff {
        &self.reference_diff
    }

    pub fn workspace(&self) -> Result<Workspace, WorkspaceError> {
        let temp_dir = new_temp_dir()?;
        let repo = GitRepo::init(temp_dir.path())?;
        repo.fetch(self.repo.path(), &[&self.spec.start_commit])?;
        repo.checkout_branch(&self.spec.branch, &self.spec.start_commit)?;
        Workspace::from_temp_dir(temp_dir, self.spec.subdir.clone())
    }

    pub fn agent_diff(&self, workspace: &Workspace) -> Result<GitDiff, WorkspaceError> {
        let repo = GitRepo::from_path(workspace.root_path());
        let diff = repo.diff_working_tree(&self.spec.start_commit, &self.spec.diff_exclude)?;
        Ok(GitDiff::new(diff))
    }

    pub fn bundle(&self, out: &Path) -> Result<(), WorkspaceError> {
        self.repo.bundle(&[EVAL_START_REF, EVAL_GOLD_REF], &absolute(out)?)?;
        Ok(())
    }
}

const EVAL_START_REF: &str = "eval-start";
const EVAL_GOLD_REF: &str = "eval-gold";

impl GitSource {
    fn remote(&self) -> Result<OsString, WorkspaceError> {
        match self {
            Self::Url(url) => Ok(url.into()),
            Self::Bundle(path) => {
                let path = absolute(path)?;
                if !path.exists() {
                    return Err(WorkspaceError::MissingBundle { path });
                }
                Ok(path.into_os_string())
            }
        }
    }
}

fn default_branch() -> String {
    "main".to_string()
}

fn absolute(path: &Path) -> Result<PathBuf, WorkspaceError> {
    std::path::absolute(path).map_err(|source| WorkspaceError::ResolvePath { path: path.to_path_buf(), source })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::{create_dir_all, read_to_string, remove_file, write};
    use utils::testing::{TestWorkspace, git};

    #[test]
    fn a_bundled_fixture_round_trips_checkout_subdir_and_diffs() {
        let (_repo, spec) = init_repo();
        let bundle_dir = tempfile::tempdir().unwrap();
        let (fixture, workspace) =
            checkout(GitSpec { subdir: Some("pkg".into()), ..bundle_spec(&spec, bundle_dir.path()) });

        assert_eq!(read_to_string(workspace.root_path().join("root.txt")).unwrap(), "root v1\n");
        assert_eq!(workspace.relative_cwd(), Some(Path::new("pkg")));
        assert_eq!(workspace.path(), workspace.root_path().join("pkg"));
        assert!(fixture.reference_diff().diff.contains("root v2"), "reference diff should span start..gold");
        assert!(agent_diff(&fixture, &workspace).diff.is_empty(), "no agent edits yet");

        write(workspace.root_path().join("root.txt"), "root edited\n").unwrap();

        assert!(agent_diff(&fixture, &workspace).diff.contains("root edited"), "agent diff should capture the edit");
    }

    #[test]
    fn bundles_round_trip_through_relative_paths() {
        let (_repo, spec) = init_repo();
        let bundle_dir = tempfile::tempdir_in(".").unwrap();
        let cwd = std::env::current_dir().unwrap();
        let bundle_path = bundle_dir.path().strip_prefix(cwd).unwrap().join("repo.bundle");

        fixture(spec.clone()).bundle(&bundle_path).unwrap();
        let (fixture, workspace) = checkout(GitSpec { source: GitSource::Bundle(bundle_path), ..spec.clone() });

        assert_eq!(git(workspace.root_path(), &["rev-parse", "HEAD"]), spec.start_commit);
        assert!(fixture.reference_diff().diff.contains("+root v2"));
    }

    #[test]
    fn workspaces_hold_only_the_start_commit() {
        let (_repo, spec) = init_repo();
        let bundle_dir = tempfile::tempdir().unwrap();
        let fixtures = [fixture(spec.clone()), fixture(bundle_spec(&spec, bundle_dir.path()))];

        for fixture in fixtures {
            let workspace = fixture.workspace().unwrap();
            let root = workspace.root_path();
            assert_eq!(git(root, &["rev-parse", "HEAD"]), spec.start_commit);
            assert_eq!(git(root, &["for-each-ref", "--format=%(refname)"]), "refs/heads/main");
            assert_eq!(git(root, &["remote"]), "");
            assert!(
                utils::git::git(Some(root), &["cat-file", "-e", &spec.gold_commit]).is_err(),
                "the gold commit must not be in the agent's repository"
            );
            assert!(fixture.reference_diff().diff.contains("+root v2"));
        }
    }

    #[test]
    fn each_workspace_starts_fresh() {
        let (_repo, spec) = init_repo();
        let (fixture, first) = checkout(spec);
        write(first.join("root.txt"), "agent\n").unwrap();

        let second = fixture.workspace().unwrap();

        assert_ne!(first.root_path(), second.root_path());
        assert_eq!(read_to_string(second.join("root.txt")).unwrap(), "root v1\n");
    }

    #[test]
    fn workspaces_check_out_the_requested_branch() {
        let (_repo, spec) = init_repo();
        let (_fixture, workspace) = checkout(GitSpec { branch: "trunk".into(), ..spec });

        assert_eq!(git(workspace.root_path(), &["branch", "--show-current"]), "trunk");
    }

    #[test]
    fn diff_exclude_leaves_paths_out_of_the_reference_and_agent_diffs() {
        let (_repo, spec) = init_repo();
        let (fixture, workspace) = checkout(GitSpec { diff_exclude: vec!["*root.txt".into()], ..spec });
        write(workspace.join("root.txt"), "agent\n").unwrap();
        write(workspace.join("pkg/inner.txt"), "agent inner\n").unwrap();

        let agent_diff = agent_diff(&fixture, &workspace);

        assert_eq!(fixture.reference_diff().diff, "");
        assert!(agent_diff.diff.contains("+agent inner"));
        assert!(!agent_diff.diff.contains("root.txt"));
    }

    #[test]
    fn agent_diff_includes_committed_changes() {
        let (_repo, spec) = init_repo();
        let (fixture, workspace) = checkout(spec);

        write(workspace.join("root.txt"), "agent committed\n").unwrap();
        git(workspace.root_path(), &["add", "."]);
        git(
            workspace.root_path(),
            &["-c", "user.email=agent@example.com", "-c", "user.name=Agent", "commit", "-m", "agent change"],
        );

        let agent_diff = agent_diff(&fixture, &workspace);

        assert!(agent_diff.diff.contains("+agent committed"));
        assert_eq!(agent_diff.stats.files_changed, 1);
        assert_eq!(agent_diff.stats.lines_added, 1);
        assert_eq!(agent_diff.stats.lines_removed, 1);
    }

    #[test]
    fn agent_diff_includes_staged_unstaged_added_deleted_and_untracked_changes() {
        let (_repo, spec) = init_repo();
        let (fixture, workspace) = checkout(spec);

        write(workspace.join("root.txt"), "staged change\n").unwrap();
        write(workspace.join("added.txt"), "added\n").unwrap();
        git(workspace.root_path(), &["add", "root.txt", "added.txt"]);
        write(workspace.join("pkg/inner.txt"), "unstaged change\n").unwrap();
        remove_file(workspace.join("deleted.txt")).unwrap();
        create_dir_all(workspace.join("dir")).unwrap();
        write(workspace.join("dir/untracked.txt"), "untracked\n").unwrap();

        let agent_diff = agent_diff(&fixture, &workspace);

        assert!(agent_diff.diff.contains("root.txt"));
        assert!(agent_diff.diff.contains("+staged change"));
        assert!(agent_diff.diff.contains("pkg/inner.txt"));
        assert!(agent_diff.diff.contains("+unstaged change"));
        assert!(agent_diff.diff.contains("added.txt"));
        assert!(agent_diff.diff.contains("+added"));
        assert!(agent_diff.diff.contains("deleted.txt"));
        assert!(agent_diff.diff.contains("-before deleted"));
        assert!(agent_diff.diff.contains("+untracked"));
        assert_eq!(agent_diff.stats.files_changed, 5);
        assert_eq!(agent_diff.stats.lines_added, 4);
        assert_eq!(agent_diff.stats.lines_removed, 3);
        assert!(git(workspace.root_path(), &["status", "--porcelain"]).contains("?? dir/"), "the index is untouched");
    }

    #[test]
    fn agent_diff_keeps_files_tracked_despite_gitignore() {
        let repo = TestWorkspace::new().file(".gitignore", "*.env\n").git_init();
        repo.write("app.env", "start\n");
        repo.git(&["add", "--force", "app.env"]);
        let start_commit = repo.commit("start");
        let source = GitSource::Url(format!("file://{}", repo.root().display()));
        let (fixture, workspace) = checkout(GitSpec::new(source, start_commit.clone(), start_commit));

        let agent_diff = agent_diff(&fixture, &workspace);

        assert_eq!(agent_diff.diff, "");
    }

    #[test]
    fn agent_diff_rebuilds_a_deleted_index() {
        let (_repo, spec) = init_repo();
        let (fixture, workspace) = checkout(spec);
        write(workspace.join("root.txt"), "agent\n").unwrap();
        remove_file(workspace.root_path().join(".git/index")).unwrap();

        let agent_diff = agent_diff(&fixture, &workspace);

        assert!(agent_diff.diff.contains("+agent"));
        assert_eq!(agent_diff.stats.files_changed, 1);
    }

    #[test]
    fn agent_diff_rebuilds_a_deleted_index_keeping_files_tracked_despite_gitignore() {
        let repo = TestWorkspace::new().file(".gitignore", "*.env\n").git_init();
        repo.write("app.env", "start\n");
        repo.git(&["add", "--force", "app.env"]);
        let start_commit = repo.commit("start");
        let source = GitSource::Url(format!("file://{}", repo.root().display()));
        let (fixture, workspace) = checkout(GitSpec::new(source, start_commit.clone(), start_commit));
        remove_file(workspace.root_path().join(".git/index")).unwrap();

        assert_eq!(agent_diff(&fixture, &workspace).diff, "");
    }

    #[test]
    fn agent_diff_errors_when_git_fails() {
        let (_repo, spec) = init_repo();
        let (fixture, workspace) = checkout(spec);
        write(workspace.root_path().join(".git/index"), "corrupt").unwrap();

        assert!(matches!(fixture.agent_diff(&workspace), Err(WorkspaceError::Git(_))));
    }

    #[test]
    fn fetching_a_missing_bundle_errors() {
        let spec = GitSpec::new(GitSource::Bundle(PathBuf::from("/nonexistent/repo.bundle")), "abc", "def");

        assert!(matches!(GitFixture::fetch(spec), Err(WorkspaceError::MissingBundle { .. })));
    }

    fn fixture(spec: GitSpec) -> GitFixture {
        GitFixture::fetch(spec).unwrap()
    }

    fn checkout(spec: GitSpec) -> (GitFixture, Workspace) {
        let fixture = fixture(spec);
        let workspace = fixture.workspace().unwrap();
        (fixture, workspace)
    }

    fn agent_diff(fixture: &GitFixture, workspace: &Workspace) -> GitDiff {
        fixture.agent_diff(workspace).unwrap()
    }

    fn bundle_spec(spec: &GitSpec, dir: &Path) -> GitSpec {
        let bundle_path = dir.join("repo.bundle");
        fixture(spec.clone()).bundle(&bundle_path).unwrap();
        GitSpec { source: GitSource::Bundle(bundle_path), ..spec.clone() }
    }

    fn init_repo() -> (TestWorkspace, GitSpec) {
        let repo = TestWorkspace::new()
            .file("root.txt", "root v1\n")
            .file("deleted.txt", "before deleted\n")
            .file("pkg/inner.txt", "inner v1\n")
            .git_init();
        let start_commit = repo.commit("start");
        repo.write("root.txt", "root v2\n");
        let gold_commit = repo.commit("gold");
        let source = GitSource::Url(format!("file://{}", repo.root().display()));
        (repo, GitSpec::new(source, start_commit, gold_commit))
    }
}