a3s 0.8.2

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
#![cfg(unix)]

#[allow(dead_code)]
mod support;

use std::process::{Command, Output};
use std::sync::{
    atomic::{AtomicBool, Ordering},
    Arc,
};
use std::{io::Read, io::Write, net::TcpListener};

use support::{a3s_bin, make_executable, TempWorkspace};

fn run(home: &std::path::Path, config: &std::path::Path, args: &[&str]) -> Output {
    Command::new(a3s_bin())
        .args(args)
        .env("HOME", home)
        .env("A3S_CONFIG_FILE", config)
        .env_remove("CLAUDE_CODE_OAUTH_TOKEN")
        .env_remove("ANTHROPIC_AUTH_TOKEN")
        .env_remove("CODEX_HOME")
        .env("A3S_CODEBUDDY_CLI", home.join("bin/codebuddy"))
        .env("PATH", home.join("bin"))
        .env("RUST_BACKTRACE", "0")
        .output()
        .unwrap_or_else(|error| panic!("failed to run a3s {args:?}: {error}"))
}

fn fixture() -> (TempWorkspace, std::path::PathBuf, std::path::PathBuf) {
    let workspace = TempWorkspace::new("model-commands");
    let home = workspace.path("home");
    let config = workspace.path("config.acl");
    std::fs::create_dir_all(&home).unwrap();
    std::fs::write(
        &config,
        r#"
default_model = "openai/gpt-test"
providers "openai" {
  apiKey = "not-used"
  baseUrl = "https://example.invalid/v1"
  models "gpt-test" {
    name = "GPT Test"
    reasoning = true
    toolCall = true
    limit = { context = 32000, output = 1024 }
  }
}
"#,
    )
    .unwrap();
    (workspace, home, config)
}

#[test]
fn model_list_use_current_and_reset_share_one_selection() {
    let (_workspace, home, config) = fixture();

    let list = run(&home, &config, &["model", "list"]);
    assert!(
        list.status.success(),
        "{}",
        String::from_utf8_lossy(&list.stderr)
    );
    let stdout = String::from_utf8_lossy(&list.stdout);
    assert!(stdout.contains("openai/gpt-test"));
    assert!(stdout.contains("config.acl"));
    assert!(stdout.contains("context=32000"));

    let config_path = run(&home, &config, &["model", "config"]);
    assert_eq!(
        String::from_utf8_lossy(&config_path.stdout).trim(),
        config.display().to_string()
    );

    let use_model = run(&home, &config, &["model", "use", "openai/gpt-test"]);
    assert!(use_model.status.success());
    assert!(String::from_utf8_lossy(&use_model.stdout).contains("Active model: openai/gpt-test"));

    let selection = std::fs::read_to_string(home.join(".a3s/tui/model-selection.json")).unwrap();
    assert!(selection.contains(r#""source": "config""#));
    assert!(selection.contains(r#""model": "openai/gpt-test""#));

    let current = run(&home, &config, &["model", "current"]);
    assert_eq!(
        String::from_utf8_lossy(&current.stdout),
        "openai/gpt-test\n"
    );

    let reset = run(&home, &config, &["model", "reset"]);
    assert!(reset.status.success());
    assert!(!home.join(".a3s/tui/model-selection.json").exists());

    let current = run(&home, &config, &["model", "current"]);
    assert_eq!(
        String::from_utf8_lossy(&current.stdout),
        "openai/gpt-test (config.acl default)\n"
    );
}

#[test]
fn model_use_rejects_routes_missing_from_the_catalog() {
    let (_workspace, home, config) = fixture();
    let output = run(&home, &config, &["model", "use", "codex/not-entitled"]);
    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("is not available"));
    assert!(!home.join(".a3s/tui/model-selection.json").exists());
}

#[test]
fn claude_code_login_models_are_selectable_without_copying_credentials() {
    let (_workspace, home, config) = fixture();
    std::fs::create_dir_all(home.join(".claude")).unwrap();
    std::fs::write(
        home.join(".claude/.credentials.json"),
        r#"{"claudeAiOauth":{"accessToken":"claude-secret"}}"#,
    )
    .unwrap();
    std::fs::write(
        home.join(".claude.json"),
        r#"{"projects":{"demo":{"model":"claude-opus-4-6"}}}"#,
    )
    .unwrap();

    let list = run(&home, &config, &["model", "list"]);
    assert!(list.status.success());
    let stdout = String::from_utf8_lossy(&list.stdout);
    assert!(stdout.contains("claude-code/claude-opus-4-6"));
    assert!(stdout.contains("Claude Code"));
    assert!(!stdout.contains("claude-secret"));

    let selected = run(
        &home,
        &config,
        &["model", "use", "claude-code/claude-opus-4-6"],
    );
    assert!(selected.status.success());
    let preference = std::fs::read_to_string(home.join(".a3s/tui/model-selection.json")).unwrap();
    assert!(preference.contains(r#""source": "claude""#));
    assert!(!preference.contains("claude-secret"));
}

#[test]
fn codex_login_models_are_selectable_from_the_product_cache() {
    let (_workspace, home, config) = fixture();
    std::fs::create_dir_all(home.join(".codex")).unwrap();
    std::fs::write(
        home.join(".codex/auth.json"),
        r#"{"tokens":{"id_token":"header.eyJleHAiOjF9.signature","access_token":"codex-secret","refresh_token":"codex-refresh-secret"}}"#,
    )
    .unwrap();
    std::fs::write(
        home.join(".codex/models_cache.json"),
        r#"{"models":[{"slug":"gpt-test-codex","visibility":"list","priority":1,"context_window":64000}]}"#,
    )
    .unwrap();

    let list = run(&home, &config, &["model", "list"]);
    assert!(list.status.success());
    let stdout = String::from_utf8_lossy(&list.stdout);
    assert!(stdout.contains("codex/gpt-test-codex"));
    assert!(stdout.contains("context=64000"));
    assert!(!stdout.contains("codex-secret"));

    let selected = run(&home, &config, &["model", "use", "codex/gpt-test-codex"]);
    assert!(selected.status.success());
    let preference = std::fs::read_to_string(home.join(".a3s/tui/model-selection.json")).unwrap();
    assert!(preference.contains(r#""source": "codex""#));
    assert!(!preference.contains("codex-secret"));
}

#[test]
fn workbuddy_login_models_are_discovered_without_copying_account_state() {
    let (_workspace, home, config) = fixture();
    std::fs::create_dir_all(home.join(".workbuddy")).unwrap();
    std::fs::create_dir_all(home.join("bin")).unwrap();
    std::fs::write(
        home.join(".workbuddy/settings.json"),
        r#"{"privateAccountState":"workbuddy-secret"}"#,
    )
    .unwrap();
    make_executable(
        &home.join("bin/codebuddy"),
        "#!/bin/sh\nprintf '%s\\n' 'Currently supported models for your account:' '  - glm-5.1' '  - kimi-k2.7'\n",
    );

    let list = run(&home, &config, &["model", "list"]);
    assert!(
        list.status.success(),
        "{}",
        String::from_utf8_lossy(&list.stderr)
    );
    let stdout = String::from_utf8_lossy(&list.stdout);
    assert!(stdout.contains("workbuddy/glm-5.1"));
    assert!(stdout.contains("workbuddy/kimi-k2.7"));
    assert!(stdout.contains("WorkBuddy"));
    assert!(!stdout.contains("workbuddy-secret"));

    let selected = run(&home, &config, &["model", "use", "workbuddy/glm-5.1"]);
    assert!(
        selected.status.success(),
        "{}",
        String::from_utf8_lossy(&selected.stderr)
    );
    let preference = std::fs::read_to_string(home.join(".a3s/tui/model-selection.json")).unwrap();
    assert!(preference.contains(r#""source": "codebuddy""#));
    assert!(!preference.contains("workbuddy-secret"));
}

#[test]
fn a3s_os_gateway_models_are_selectable_without_storing_the_os_token() {
    let (_workspace, home, config) = fixture();
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let address = listener.local_addr().unwrap();
    let server = std::thread::spawn(move || {
        for _ in 0..3 {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = [0_u8; 4096];
            let size = stream.read(&mut request).unwrap();
            let request = String::from_utf8_lossy(&request[..size]);
            assert!(request
                .to_ascii_lowercase()
                .contains("authorization: bearer os-secret"));
            let body = if request.starts_with("GET /api/v1/users/me ") {
                r#"{"data":{"displayName":"OS Test User"}}"#
            } else {
                assert!(request.starts_with("GET /api/v1/llm/models "));
                r#"{"data":[{"id":"gateway-model","context_length":128000}]}"#
            };
            write!(
                stream,
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                body.len(),
                body
            )
            .unwrap();
        }
    });
    let mut body = std::fs::read_to_string(&config).unwrap();
    body.push_str(&format!("\nos = \"http://{address}\"\n"));
    std::fs::write(&config, body).unwrap();

    let login = run(&home, &config, &["login", "os-secret"]);
    assert!(login.status.success());
    let list = run(&home, &config, &["model", "list"]);
    assert!(
        list.status.success(),
        "{}",
        String::from_utf8_lossy(&list.stderr)
    );
    let stdout = String::from_utf8_lossy(&list.stdout);
    assert!(
        stdout.contains("a3s-os/gateway-model"),
        "stdout: {stdout}\nstderr: {}",
        String::from_utf8_lossy(&list.stderr)
    );
    assert!(stdout.contains("context=128000"));
    assert!(!stdout.contains("os-secret"));

    let selected = run(&home, &config, &["model", "use", "a3s-os/gateway-model"]);
    assert!(selected.status.success());
    let preference = std::fs::read_to_string(home.join(".a3s/tui/model-selection.json")).unwrap();
    assert!(preference.contains(r#""source": "os_gateway""#));
    assert!(!preference.contains("os-secret"));
    server.join().unwrap();
}

#[test]
fn selecting_a_config_model_does_not_refresh_unrelated_accounts() {
    let (_workspace, home, config) = fixture();
    std::fs::create_dir_all(home.join(".codex")).unwrap();
    std::fs::create_dir_all(home.join("bin")).unwrap();
    std::fs::write(
        home.join(".codex/auth.json"),
        r#"{"tokens":{"access_token":"codex-secret"}}"#,
    )
    .unwrap();
    let probe = home.join("codex-probed");
    make_executable(
        &home.join("bin/codex"),
        &format!(
            "#!/bin/sh\nprintf probed > '{}'\n/bin/sleep 3\nprintf '%s\\n' '{{\"models\":[]}}'\n",
            probe.display()
        ),
    );

    let output = run(&home, &config, &["model", "use", "openai/gpt-test"]);
    assert!(output.status.success());
    assert!(!probe.exists(), "Codex was probed for a config-only route");
}

#[test]
fn model_list_discovers_slow_remote_sources_concurrently() {
    let (_workspace, home, config) = fixture();
    std::fs::create_dir_all(home.join(".codex")).unwrap();
    std::fs::create_dir_all(home.join("bin")).unwrap();
    std::fs::write(
        home.join(".codex/auth.json"),
        r#"{"tokens":{"access_token":"codex-secret"}}"#,
    )
    .unwrap();
    let codex_started = home.join("codex-started");
    let os_started = home.join("os-started");
    let codex_saw_os = home.join("codex-saw-os");
    make_executable(
        &home.join("bin/codex"),
        &format!(
            "#!/bin/sh\nprintf started > '{}'\n\
             attempts=0\n\
             while [ ! -e '{}' ] && [ \"$attempts\" -lt 100 ]; do\n\
               /bin/sleep 0.05\n\
               attempts=$((attempts + 1))\n\
             done\n\
             if [ -e '{}' ]; then printf observed > '{}'; fi\n\
             printf '%s\\n' '{{\"models\":[{{\"slug\":\"codex-slow\",\"visibility\":\"list\"}}]}}'\n",
            codex_started.display(),
            os_started.display(),
            os_started.display(),
            codex_saw_os.display(),
        ),
    );

    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let address = listener.local_addr().unwrap();
    let os_saw_codex = Arc::new(AtomicBool::new(false));
    let os_saw_codex_from_server = Arc::clone(&os_saw_codex);
    let codex_started_from_server = codex_started.clone();
    let os_started_from_server = os_started.clone();
    let server = std::thread::spawn(move || {
        for _ in 0..2 {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = [0_u8; 4096];
            let amount = stream.read(&mut request).unwrap();
            assert!(amount > 0, "expected an HTTP request");
            let request = String::from_utf8_lossy(&request[..amount]);
            let body = if request.starts_with("GET /api/v1/users/me ") {
                r#"{"data":{"displayName":"OS Slow User"}}"#
            } else {
                assert!(request.starts_with("GET /api/v1/llm/models "));
                std::fs::write(&os_started_from_server, "started").unwrap();
                for _ in 0..100 {
                    if codex_started_from_server.exists() {
                        os_saw_codex_from_server.store(true, Ordering::SeqCst);
                        break;
                    }
                    std::thread::sleep(std::time::Duration::from_millis(20));
                }
                r#"{"data":[{"id":"os-slow"}]}"#
            };
            write!(
                stream,
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                body.len(),
                body
            )
            .unwrap();
        }
    });
    let mut body = std::fs::read_to_string(&config).unwrap();
    body.push_str(&format!("\nos = \"http://{address}\"\n"));
    std::fs::write(&config, body).unwrap();
    assert!(run(&home, &config, &["login", "os-secret"])
        .status
        .success());

    let output = run(&home, &config, &["model", "list"]);
    server.join().unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("codex/codex-slow"));
    assert!(stdout.contains("a3s-os/os-slow"));
    assert!(
        codex_saw_os.exists() && os_saw_codex.load(Ordering::SeqCst),
        "remote discovery did not overlap both sources; stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
}