aether-agent-cli 0.9.2

CLI and ACP server for the Aether AI coding agent
Documentation
use acp_utils::notifications::AetherCapabilities;
use aether_auth::OAuthCredentialStorage;
use aether_cli::acp::testing::AcpTestHarness;
use agent_client_protocol::schema::{ProtocolVersion, v2 as acp};
use serde_json::json;

#[tokio::test(flavor = "current_thread")]
async fn initialize_exposes_only_v2_nested_capabilities() {
    AcpTestHarness::run(|harness| async move {
        let response = harness.initialize_response;
        assert_eq!(response.protocol_version, ProtocolVersion::V2);
        assert_eq!(response.info.name, "Aether");
        let wire = serde_json::to_value(&response).unwrap();
        let session = response.capabilities.session.unwrap();
        assert!(session.prompt.as_ref().unwrap().embedded_context.is_some());
        assert!(session.mcp.as_ref().unwrap().stdio.is_some());
        assert!(session.mcp.as_ref().unwrap().http.is_some());
        let meta = AetherCapabilities::from_meta(session.meta.as_ref());
        assert!(meta.prompt_search && meta.session_preview && meta.workspace_move);
        assert_eq!(AetherCapabilities::from_meta(session.prompt.unwrap().meta.as_ref()), AetherCapabilities::default());
        for key in ["loadSession", "list", "resume", "close", "sse"] {
            assert!(!wire.to_string().contains(&format!("\"{key}\":")));
        }
    })
    .await;
}

#[tokio::test(flavor = "current_thread")]
async fn login_and_logout_publish_credential_and_config_state() {
    AcpTestHarness::run(|mut harness| async move {
        let session = harness.insert_agent_switching_session().await;
        harness.oauth_store.save("unrelated-provider", json!({"token": "preserved"})).await.unwrap();
        assert!(!harness.oauth_store.contains("codex"));
        harness.client_cx.send_request(acp::LoginAuthRequest::new("codex")).block_task().await.unwrap();
        assert!(harness.oauth_store.contains("codex"));
        let methods = harness.auth_updates.recv().await.unwrap().auth_methods;
        assert!(authenticated(&methods));
        let logged_in = next_config(&mut harness, session.session_id()).await;

        harness.client_cx.send_request(acp::LogoutAuthRequest::new()).block_task().await.unwrap();
        assert!(harness.oauth_store.load("codex").await.unwrap().is_none());
        assert!(harness.oauth_store.contains("unrelated-provider"));
        let methods = harness.auth_updates.recv().await.unwrap().auth_methods;
        assert!(!authenticated(&methods));
        let logged_out = next_config(&mut harness, session.session_id()).await;
        assert_ne!(logged_in, logged_out, "credential state changes the model login hints");
        let wire = serde_json::to_value(logged_out).unwrap();
        assert!(wire.to_string().contains("Needs login"));
        assert!(
            wire.as_array()
                .unwrap()
                .iter()
                .all(|option| option.get("configId").is_some() && option.get("id").is_none())
        );

        harness.client_cx.send_request(acp::LogoutAuthRequest::new()).block_task().await.unwrap();
        assert!(!harness.oauth_store.contains("codex"), "logout is idempotent");
        assert!(harness.client_cx.send_request(acp::LoginAuthRequest::new("unknown")).block_task().await.is_err());
    })
    .await;
}

#[test]
fn config_values_are_tagged_and_unknown_categories_round_trip() {
    let id = acp::SetSessionConfigOptionRequest::new("session", "model", "model-id");
    let wire = serde_json::to_value(id).unwrap();
    assert_eq!(wire["configId"], "model");
    assert_eq!(wire["type"], "id");
    assert_eq!(wire["value"], "model-id");
    let boolean = acp::SetSessionConfigOptionRequest::new(
        "session",
        "flag",
        acp::SessionConfigOptionValue::Boolean { value: true },
    );
    let wire = serde_json::to_value(boolean).unwrap();
    assert_eq!(wire["type"], "boolean");
    assert_eq!(wire["value"], true);
    let wire = json!({"configId": "custom", "name": "Custom", "category": "future_category", "type": "select", "currentValue": "one", "options": []});
    let option: acp::SessionConfigOption = serde_json::from_value(wire).unwrap();
    assert_eq!(serde_json::to_value(option).unwrap()["category"], "future_category");
    let group = acp::SessionConfigSelectGroup::new("provider", "Provider", vec![]);
    assert_eq!(group.group_id.0.as_ref(), "provider");
    let wire = serde_json::to_value(group).unwrap();
    assert_eq!(wire["groupId"], "provider");
    assert!(wire.get("group").is_none());
}

#[test]
fn new_and_resume_default_omitted_lists_to_empty() {
    let omitted: acp::NewSessionRequest = serde_json::from_value(json!({"cwd": "/tmp"})).unwrap();
    let empty: acp::NewSessionRequest =
        serde_json::from_value(json!({"cwd": "/tmp", "additionalDirectories": [], "mcpServers": []})).unwrap();
    assert_eq!(omitted, empty);
    let omitted: acp::ResumeSessionRequest =
        serde_json::from_value(json!({"sessionId": "saved", "cwd": "/tmp"})).unwrap();
    let empty: acp::ResumeSessionRequest = serde_json::from_value(
        json!({"sessionId": "saved", "cwd": "/tmp", "additionalDirectories": [], "mcpServers": []}),
    )
    .unwrap();
    assert_eq!(omitted, empty);
}

#[tokio::test(flavor = "current_thread")]
async fn resume_accepts_empty_or_omitted_lists_and_rejects_unknown_cursors() {
    AcpTestHarness::run(|harness| async move {
        harness.append_stored_session("saved", "2026-05-01T00:00:00Z");
        for wire in [
            json!({"sessionId": "saved", "cwd": "/tmp"}),
            json!({"sessionId": "saved", "cwd": "/tmp", "additionalDirectories": [], "mcpServers": []}),
        ] {
            let request: acp::ResumeSessionRequest = serde_json::from_value(wire).unwrap();
            harness.client_cx.send_request(request).block_task().await.unwrap();
            harness.client_cx.send_request(acp::CloseSessionRequest::new("saved")).block_task().await.unwrap();
        }
        let request: acp::ResumeSessionRequest = serde_json::from_value(
            json!({"sessionId": "saved", "cwd": "/tmp", "replayFrom": {"type": "future_cursor"}}),
        )
        .unwrap();
        assert!(harness.client_cx.send_request(request).block_task().await.is_err());
    })
    .await;
}

fn authenticated(methods: &[acp::AuthMethod]) -> bool {
    methods.iter().any(|method| matches!(method, acp::AuthMethod::Agent(method) if method.method_id.0.as_ref() == "codex" && method.description.as_deref() == Some("authenticated")))
}

async fn next_config(harness: &mut AcpTestHarness, session_id: &acp::SessionId) -> Vec<acp::SessionConfigOption> {
    loop {
        let notification = harness.peer.next_session_notification().await;
        if notification.session_id == *session_id
            && let acp::SessionUpdate::ConfigOptionUpdate(update) = notification.update
        {
            return update.config_options;
        }
    }
}