aether-agent-cli 0.7.13

CLI and ACP server for the Aether AI coding agent
Documentation
use acp_utils::notifications::{
    WorkspaceListParams, WorkspaceListResponse, WorkspaceMoveParams, WorkspaceMoveResponse, WorkspaceMoveTarget,
};
use aether_cli::acp::testing::AcpTestHarness;
use aether_cli::workspace::testing::{clone_repo, git, git_status, init_repo};
use agent_client_protocol::schema::ListSessionsRequest;
use std::fs;
use std::future::Future;
use tokio::task::LocalSet;

#[tokio::test(flavor = "current_thread")]
async fn workspace_list_registers_source_repo_and_marks_it_current() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        let sub = repo.join("sub");
        fs::create_dir_all(&sub).unwrap();
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &sub);

        let response = list(&harness, "s1").await;

        assert_eq!(response.workspaces.len(), 1);
        let entry = &response.workspaces[0];
        assert!(entry.is_current);
        assert_eq!(entry.path, repo.canonicalize().unwrap());
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_move_to_new_sibling_clones_changes_and_resets_source() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo);

        fs::write(repo.join("committed.txt"), "modified\n").unwrap();
        git(&repo, &["add", "committed.txt"]);
        fs::write(repo.join("untracked.txt"), "new file\n").unwrap();

        let response = move_workspace(&harness, "s1", WorkspaceMoveTarget::New { name: "repo-2".to_string() })
            .await
            .expect("move succeeds");

        let clone = tmp.path().join("repo-2");
        assert_eq!(response.new_cwd, clone.canonicalize().unwrap());
        assert_eq!(fs::read_to_string(clone.join("committed.txt")).unwrap(), "modified\n");
        assert_eq!(fs::read_to_string(clone.join("untracked.txt")).unwrap(), "new file\n");
        assert!(git_status(&repo).is_empty(), "source should be clean after move");

        let sessions =
            harness.client_cx.send_request(ListSessionsRequest::new()).block_task().await.expect("list sessions");
        let session = sessions.sessions.iter().find(|s| s.session_id.0.as_ref() == "s1").expect("session exists");
        assert_eq!(session.cwd, response.new_cwd);
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_move_to_existing_clean_target_applies_changes() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        let clone = clone_repo(&repo, tmp.path().join("clone"));
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo);

        fs::write(repo.join("committed.txt"), "edited\n").unwrap();

        let response = move_workspace(&harness, "s1", WorkspaceMoveTarget::Existing { path: clone.clone() })
            .await
            .expect("move succeeds");

        assert_eq!(response.new_cwd, clone.canonicalize().unwrap());
        assert_eq!(fs::read_to_string(clone.join("committed.txt")).unwrap(), "edited\n");
        assert!(git_status(&repo).is_empty(), "source should be clean after move");
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_move_to_existing_target_on_different_head_fails_actionably() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        let clone = clone_repo(&repo, tmp.path().join("clone"));
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo);

        fs::write(clone.join("target-only.txt"), "target commit\n").unwrap();
        git(&clone, &["add", "."]);
        git(&clone, &["commit", "-m", "target diverges"]);
        fs::write(repo.join("committed.txt"), "edited\n").unwrap();

        let error = move_workspace(&harness, "s1", WorkspaceMoveTarget::Existing { path: clone })
            .await
            .expect_err("move should fail");

        assert!(error.message.contains("different commit"), "unexpected error: {}", error.message);
        assert_eq!(fs::read_to_string(repo.join("committed.txt")).unwrap(), "edited\n");
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_move_to_dirty_target_fails_and_leaves_source_untouched() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        let clone = clone_repo(&repo, tmp.path().join("clone"));
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo);

        fs::write(repo.join("committed.txt"), "edited\n").unwrap();
        fs::write(clone.join("dirty.txt"), "target change\n").unwrap();

        let error = move_workspace(&harness, "s1", WorkspaceMoveTarget::Existing { path: clone })
            .await
            .expect_err("move should fail");

        assert!(error.message.contains("uncommitted changes"), "unexpected error: {}", error.message);
        assert_eq!(fs::read_to_string(repo.join("committed.txt")).unwrap(), "edited\n");
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_move_rejects_workspace_from_different_repository() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        let other = init_repo(tmp.path(), "other");
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo);

        let error = move_workspace(&harness, "s1", WorkspaceMoveTarget::Existing { path: other })
            .await
            .expect_err("move should fail");

        assert!(error.message.contains("different repository"), "unexpected error: {}", error.message);
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_move_rejects_invalid_new_names() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        init_repo(tmp.path(), "taken");
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo);

        for name in ["", "a/b", "..", "taken"] {
            let result = move_workspace(&harness, "s1", WorkspaceMoveTarget::New { name: name.to_string() }).await;
            assert!(result.is_err(), "name {name:?} should be rejected");
        }
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_list_omits_deleted_workspaces() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &repo);

        move_workspace(&harness, "s1", WorkspaceMoveTarget::New { name: "repo-2".to_string() })
            .await
            .expect("move succeeds");
        harness.append_stored_session_in("s2", "2026-05-02T00:00:00Z", &repo);
        assert_eq!(list(&harness, "s2").await.workspaces.len(), 2);

        fs::remove_dir_all(tmp.path().join("repo-2")).unwrap();

        let response = list(&harness, "s2").await;
        assert_eq!(response.workspaces.len(), 1);
        assert!(response.workspaces[0].is_current);
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn workspace_move_relocates_session_to_matching_subdirectory() {
    with_harness(|harness| async move {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(tmp.path(), "repo");
        let sub = repo.join("sub");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("file.txt"), "content\n").unwrap();
        git(&repo, &["add", "."]);
        git(&repo, &["commit", "-m", "add subdir"]);
        harness.append_stored_session_in("s1", "2026-05-01T00:00:00Z", &sub);

        let response = move_workspace(&harness, "s1", WorkspaceMoveTarget::New { name: "repo-2".to_string() })
            .await
            .expect("move succeeds");

        assert_eq!(response.new_cwd, tmp.path().join("repo-2").canonicalize().unwrap().join("sub"));
    })
    .await;
}

async fn with_harness<F, Fut>(body: F)
where
    F: FnOnce(AcpTestHarness) -> Fut,
    Fut: Future<Output = ()>,
{
    LocalSet::new()
        .run_until(async move {
            let harness = AcpTestHarness::start().await;
            body(harness).await;
        })
        .await;
}

async fn list(harness: &AcpTestHarness, session_id: &str) -> WorkspaceListResponse {
    harness
        .client_cx
        .send_request(WorkspaceListParams { session_id: session_id.to_string() })
        .block_task()
        .await
        .expect("workspace list succeeds")
}

async fn move_workspace(
    harness: &AcpTestHarness,
    session_id: &str,
    target: WorkspaceMoveTarget,
) -> Result<WorkspaceMoveResponse, agent_client_protocol::schema::Error> {
    harness
        .client_cx
        .send_request(WorkspaceMoveParams { session_id: session_id.to_string(), target })
        .block_task()
        .await
}