From c5f120bd98aa7177b92571a9744a18493f255e67 Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Thu, 20 Aug 2026 21:57:37 -0700 Subject: [PATCH] feat(desktop): add Antigravity as a PATH-probed harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserves the `antigravity` tier-1 runtime ID and surfaces Google Antigravity in the Desktop runtime catalog when the `agy` CLI is found on PATH. Antigravity is PATH-probed only — Buzz does not bundle or download a runtime for it. Google does not currently publish an ACP endpoint for Antigravity: `agy 1.1.17` exposes `agent`, `mcp`, `models`, `plugin`, and `update` subcommands plus `--print --output-format stream-json`, with no ACP `session/new` mode. Reserving the ID now keeps a future managed or shim integration from breaking custom-harness IDs, and stops a user-defined harness from shadowing `antigravity`. - Register the `antigravity` KnownAcpRuntime with `agy` as its command - Reserve the ID in BUILTIN_IDS so custom harnesses cannot claim it - Normalize `.par` command suffixes alongside `.exe`/`.cmd`/`.bat` - Show Antigravity in onboarding order and readiness selection - Add a neutral placeholder mark; record provenance in CREDITS.md An ACP shim over `agy -p --output-format stream-json` is the natural follow-up once the wire format is pinned down; it needs its own design for permissions, tool calls, and cancellation. Signed-off-by: Sanjay Ramadugu --- crates/buzz-acp/README.md | 25 +- crates/buzz-acp/src/config.rs | 75 ++++- desktop/public/harness-logos/CREDITS.md | 2 + desktop/public/harness-logos/antigravity.svg | 3 + .../src/managed_agents/custom_harnesses.rs | 42 ++- .../src-tauri/src/managed_agents/discovery.rs | 45 ++- .../discovery/runtime_metadata.rs | 14 + .../src/managed_agents/discovery/tests.rs | 140 ++++++++- .../tests/challenger_m1_stress_tests.rs | 282 ++++++++++++++++++ .../features/onboarding/ui/HarnessMarks.tsx | 17 ++ .../src/features/onboarding/ui/SetupStep.tsx | 7 + .../features/onboarding/ui/agentReadiness.ts | 10 +- .../ui/onboardingRuntimeSelection.test.mjs | 7 +- .../ui/onboardingRuntimeSelection.ts | 1 + 14 files changed, 651 insertions(+), 19 deletions(-) create mode 100644 desktop/public/harness-logos/antigravity.svg create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/challenger_m1_stress_tests.rs diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..22fb3ad7104 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -9,7 +9,7 @@ Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent (send_message, etc.) ``` -Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). +Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)), and **google antigravity** (via `agy` on `PATH`, PATH-probed preset — no bundled installer). ## Prerequisites @@ -98,6 +98,25 @@ buzz-acp Older installs that still expose `claude-code-acp` are also supported. `buzz-acp` treats both Claude ACP command names as the same zero-arg runtime. +## Running with Google Antigravity + +Google Antigravity is exposed in Buzz as a PATH-probed preset (`agy` on `PATH`). Buzz does **not** bundle or download an `agy_acp_server` binary — install `agy` via Google's official Antigravity distribution and ensure `agy` is on `PATH`. + +```bash +# 1. Verify agy is on PATH +agy --version + +# 2. Run with buzz-acp (no BUZZ_ACP_AGENT_ARGS needed for agy) +export BUZZ_PRIVATE_KEY="nsec1..." +export BUZZ_ACP_AGENT_COMMAND="agy" + +buzz-acp +``` + +Buzz Desktop shows the `agy` harness when `agy` is found on `PATH` (via `common_binary_paths` + login-shell `PATH`). When unavailable, the catalog entry shows `Not installed` with a link to `https://antigravity.google`. + +> **Note:** `agy` currently exposes `agent`, `mcp`, `models`, `plugin`, `update` subcommands and `--print --output-format stream-json` (no ACP `agy_acp_server` / `session/new` mode was found on the tested `agy 1.1.17` binary). A future ACP shim (`agy -p --output-format stream-json`) or tier-2 preset with no installer is the honest integration path until Google publishes an ACP endpoint. + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). @@ -267,7 +286,7 @@ Buzz Desktop supports registering any ACP-speaking agent tool as a selectable ru ### How it works -**Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden. +**Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden. **Antigravity** (`antigravity` / `agy`) is currently a tier-1 ID reservation with PATH probing only — no managed download — to avoid alias collision and to allow a future managed or shim integration without ID breakage. **Tier-2 — preset catalog** (Cursor, Oh My Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead. @@ -318,7 +337,7 @@ To add a new runtime to the tier-2 gallery: 4. **Add a bundled logo** (64×64 PNG or optimised SVG) to `desktop/public/harness-logos/.png` and add a corresponding entry to `PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`. Record the source and license in `desktop/public/harness-logos/CREDITS.md`. Only bundle a mark whose upstream license permits redistribution; skipping this step is caught by `presetLogos.test.mjs`, which asserts every `PRESET_HARNESSES` id has a mapped logo that exists on disk. 5. Run `cargo test --lib` and `just desktop-typecheck` to verify everything compiles. -The built-in `BUILTIN_IDS` set (`goose`, `claude`, `codex`, `buzz-agent`, and all current preset ids) is the reserved namespace; every other id is available for custom harnesses. +The built-in `BUILTIN_IDS` set (`goose`, `claude`, `codex`, `antigravity`, `buzz-agent`, and all current preset ids) is the reserved namespace; every other id is available for custom harnesses. ## Using Any ACP Agent diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 5244ef5537a..fc9a786c9fd 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -715,8 +715,8 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .expect("rsplit always yields at least one element"); let lower = basename.to_ascii_lowercase(); // Windows resolves commands through `.exe` binaries and npm's `.cmd`/`.bat` - // shims; all three name the same runtime identity. - let stem = [".exe", ".cmd", ".bat"] + // shims; Python/PEX artifacts use `.par` on Unix/macOS; all name the same runtime identity. + let stem = [".exe", ".cmd", ".bat", ".par"] .iter() .find_map(|extension| lower.strip_suffix(extension)) .unwrap_or(&lower); @@ -728,9 +728,10 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .collect() } -fn default_agent_args(command: &str) -> Option> { +pub(crate) fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + "antigravity" | "google-antigravity" | "agy" | "agy-acp-server" => Some(Vec::new()), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -825,9 +826,12 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec::new()), + "expected empty args for {cmd}" + ); + assert_eq!(normalize_agent_args(cmd, Vec::new()), Vec::::new()); + assert_eq!( + normalize_agent_args(cmd, vec!["acp".into()]), + Vec::::new() + ); + } + } + + #[test] + fn normalize_agent_args_preserves_explicit_custom_antigravity_args() { + assert_eq!( + normalize_agent_args("antigravity", vec!["--flag".into(), "val".into()]), + vec!["--flag", "val"] + ); + assert_eq!( + normalize_agent_args("agy", vec!["--model".into(), "gemini".into()]), + vec!["--model", "gemini"] + ); + } + #[test] fn default_agent_env_recognizes_hermes_identities() { for command in [ diff --git a/desktop/public/harness-logos/CREDITS.md b/desktop/public/harness-logos/CREDITS.md index 716c43e1ae3..6874c3be2c0 100644 --- a/desktop/public/harness-logos/CREDITS.md +++ b/desktop/public/harness-logos/CREDITS.md @@ -15,6 +15,7 @@ license permits redistribution. | `omp.svg` | [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) | `667111575ebba136dadfd6989379e7f67e0d40d9` | MIT © 2025 Mario Zechner; © 2025–2026 Can Bölük | `assets/icon.svg` | None | | `kimi.png` | [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) | `4a550effdfcb29a25a5d325bf935296cc50cd417` | Apache-2.0; NOTICE: Kimi Code CLI © 2025 Moonshot AI | `web/public/logo.png` | None | | `grok.svg` | [SpaceXAI brand guidelines](https://x.ai/legal/brand-guidelines) | Retrieved 2026-07-25 | xAI Brand Guidelines: marks may be used to accurately refer to xAI or its services; logos must be used exactly as provided | `SpaceXAI_Grok_Assets.zip` → `Grok_Logomark_Dark.svg` | None | +| `antigravity.svg` | Placeholder neutral mark for Antigravity harness | 2026-08-20 | Neutral geometric placeholder (not a Google brand asset); nominative use only | `desktop/src/features/onboarding/ui/HarnessMarks.tsx` four-point star path | Scaled to 24×24 viewBox, `currentColor` | ## Inline SVG marks (`RUNTIME_MARKS`) @@ -26,6 +27,7 @@ Monochrome marks inlined as `currentColor` paths in |---|---|---|---|---|---| | Goose | [block/goose](https://github.com/block/goose) | `305849b71709b95b86ed9f11bd3bc939899c0aab` | Apache-2.0 © Block, Inc. | `documentation/static/img/goose.svg` | `fill="#101010"` → `currentColor`; dropped the redundant clipPath wrapper | | Cursor | [simple-icons](https://github.com/simple-icons/simple-icons) | `16.27.1` (slug `cursor`) | CC0-1.0 (path data); nominative use of the Cursor mark to identify Cursor's harness | `icons/cursor.svg` | `fill` → `currentColor` | +| Antigravity | Neutral placeholder | 2026-08-20 | Neutral placeholder mark (not a Google brand asset) | `desktop/src/features/onboarding/ui/HarnessMarks.tsx` `AntigravityMark` | `fill` → `currentColor`; single four-point star path | Codex deliberately has **no** bundled mark: the OpenAI blossom was removed from simple-icons in v16 at the vendor's request, so we do not ship it — diff --git a/desktop/public/harness-logos/antigravity.svg b/desktop/public/harness-logos/antigravity.svg new file mode 100644 index 00000000000..665ad171c44 --- /dev/null +++ b/desktop/public/harness-logos/antigravity.svg @@ -0,0 +1,3 @@ + + + diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index ba0448beaff..48b67676912 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -220,7 +220,7 @@ pub(crate) fn validate_harness_definition_pub(def: &HarnessDefinition) -> Result /// tier-1 runtimes — no hand-maintained copy. Adding a preset to /// `PRESET_HARNESSES` automatically reserves its ID without a separate edit. fn builtin_ids() -> impl Iterator { - const TIER1: &[&str] = &["goose", "claude", "codex", "buzz-agent"]; + const TIER1: &[&str] = &["goose", "claude", "codex", "buzz-agent", "antigravity"]; let tier2 = crate::managed_agents::discovery::preset_harness_ids(); TIER1.iter().copied().chain(tier2.iter().copied()) } @@ -537,7 +537,7 @@ mod tests { #[test] fn builtin_ids_are_rejected() { // Tier-1 hard-coded IDs must always be reserved. - for id in &["goose", "claude", "codex", "buzz-agent"] { + for id in &["goose", "claude", "codex", "buzz-agent", "antigravity"] { assert!(check_id_collision(id).is_err(), "{id} should be rejected"); } // Tier-2 preset IDs must also be reserved (derived from PRESET_HARNESSES). @@ -546,6 +546,44 @@ mod tests { } } + #[test] + fn check_id_collision_rejects_antigravity_case_insensitively() { + assert!(check_id_collision("antigravity").is_err()); + assert!(check_id_collision("AntiGravity").is_err()); + assert!(check_id_collision("ANTIGRAVITY").is_err()); + assert!(check_id_collision("aNtiGravity").is_err()); + assert!(check_id_collision("custom-antigravity").is_ok()); + assert!(check_id_collision("antigravity-custom").is_ok()); + } + + #[test] + fn load_custom_harnesses_drops_file_shadowing_antigravity() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("antigravity.json"), + r#"{"id":"antigravity","label":"Shadow Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + assert!( + load_custom_harnesses(dir.path()).is_empty(), + "loader must drop a custom harness file with id 'antigravity'" + ); + } + + #[test] + fn load_custom_harnesses_drops_file_shadowing_antigravity_mixed_case() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("custom.json"), + r#"{"id":"AntiGravity","label":"Shadow Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + assert!( + load_custom_harnesses(dir.path()).is_empty(), + "loader must drop a custom harness file with id 'AntiGravity'" + ); + } + #[test] fn unknown_id_passes_collision_check() { assert!(check_id_collision("my-custom-agent").is_ok()); diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 78592357c9b..7118a8ecef9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -33,6 +33,8 @@ const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/e const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; +const ANTIGRAVITY_AVATAR_URL: &str = + "https://raw.githubusercontent.com/block/buzz/refs/heads/main/desktop/public/harness-logos/antigravity.svg"; fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); PATHS.get_or_init(|| { @@ -219,6 +221,39 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ login_hint: None, auth_probe_args: None, }, + KnownAcpRuntime { + id: "antigravity", + label: "Antigravity", + commands: &["agy"], + aliases: &["antigravity", "google-antigravity"], + avatar_url: ANTIGRAVITY_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://antigravity.google.com", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz expects agy on PATH (install agy via Google Antigravity). No managed download is bundled.", + adapter_install_hint: "", + skill_dir: Some(".antigravity/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Authenticate with Google to use Antigravity."), + auth_probe_args: None, + }, ]; /// Skill discovery directories declared by known runtimes. @@ -254,7 +289,8 @@ pub(crate) fn normalize_command_identity(command: &str) -> String { _ => character.to_ascii_lowercase(), }) .collect::(); - let lower = lower.strip_suffix(".exe").unwrap_or(&lower).to_string(); + let lower = lower.strip_suffix(".exe").unwrap_or(&lower); + let lower = lower.strip_suffix(".par").unwrap_or(lower).to_string(); if let Some(suffix) = std::env::consts::EXE_SUFFIX.strip_prefix('.') { return lower @@ -448,9 +484,10 @@ pub fn try_record_agent_command( Ok(default_agent_command()) } -fn default_agent_args(command: &str) -> Option> { +pub(crate) fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + "antigravity" | "google-antigravity" | "agy" | "agy-acp-server" => Some(Vec::new()), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -472,7 +509,9 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec::new()), + "expected empty args for {cmd}" + ); + assert_eq!( + normalize_agent_args(cmd, Vec::new()), + Vec::::new(), + "expected empty args on empty input for {cmd}" + ); + assert_eq!( + normalize_agent_args(cmd, vec!["acp".into()]), + Vec::::new(), + "expected legacy 'acp' to normalize to empty for {cmd}" + ); + } +} + +#[test] +fn normalize_agent_args_preserves_explicit_custom_antigravity_args() { + assert_eq!( + normalize_agent_args("antigravity", vec!["--custom-flag".into(), "value".into()]), + vec!["--custom-flag", "value"] + ); + assert_eq!( + normalize_agent_args("agy", vec!["--model".into(), "gemini".into()]), + vec!["--model", "gemini"] + ); +} + +#[test] +fn test_antigravity_can_auto_install_matches_platform_support() { + let entries = super::discover_acp_runtimes_from(None, false); + let entry = entries + .iter() + .find(|e| e.id == "antigravity") + .expect("antigravity must be present in catalog"); + // PATH-probed preset — no managed installer + assert!( + !entry.can_auto_install, + "antigravity can_auto_install must be false for PATH-probed preset" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/challenger_m1_stress_tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/challenger_m1_stress_tests.rs new file mode 100644 index 00000000000..ef3f8999b2e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/challenger_m1_stress_tests.rs @@ -0,0 +1,282 @@ +use crate::managed_agents::custom_harnesses::{check_id_collision, load_custom_harnesses}; +use crate::managed_agents::discovery::{ + default_agent_args, known_acp_runtime, normalize_agent_args, normalize_command_identity, +}; +use std::fs; + +// ============================================================================= +// 1. COLLISION GUARD PERMUTATIONS & STRESS TESTS +// ============================================================================= + +#[test] +fn stress_collision_guard_case_permutations_of_antigravity() { + let permutations = [ + "antigravity", + "Antigravity", + "AntiGravity", + "ANTIGRAVITY", + "aNtIgRaViTy", + "AnTiGrAvItY", + "aNTIGRAVITY", + "antigravitY", + "ANTIgravITY", + ]; + + for id in permutations { + assert!( + check_id_collision(id).is_err(), + "Collision guard MUST reject case permutation: {id}" + ); + } +} + +#[test] +fn stress_collision_guard_all_tier1_case_permutations() { + let tier1_variants = [ + ("goose", ["Goose", "GOOSE", "gOoSe"]), + ("claude", ["Claude", "CLAUDE", "cLaUdE"]), + ("codex", ["Codex", "CODEX", "cOdEx"]), + ("buzz-agent", ["Buzz-Agent", "BUZZ-AGENT", "buzz-AGENT"]), + ("antigravity", ["Antigravity", "ANTIGRAVITY", "AntiGravity"]), + ]; + + for (canonical, variants) in tier1_variants { + assert!( + check_id_collision(canonical).is_err(), + "Canonical tier-1 id {canonical} must be reserved" + ); + for variant in variants { + assert!( + check_id_collision(variant).is_err(), + "Tier-1 variant {variant} must be reserved case-insensitively" + ); + } + } +} + +#[test] +fn stress_collision_guard_non_colliding_ids_pass() { + let valid_custom_ids = [ + "my-antigravity", + "antigravity-custom", + "custom-antigravity", + "antigravity2", + "anti-gravity", + "google-antigravity-custom", + "agy-custom", + "custom_agent", + "antigravity_plugin", + ]; + + for id in valid_custom_ids { + assert!( + check_id_collision(id).is_ok(), + "Valid custom id {id} must pass collision check" + ); + } +} + +#[test] +fn stress_custom_harness_loader_drops_antigravity_shadowing_files() { + let dir = tempfile::tempdir().expect("tempdir"); + + // Case 1: exact id "antigravity" + fs::write( + dir.path().join("antigravity.json"), + r#"{"id":"antigravity","label":"Fake Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + + // Case 2: uppercase id "ANTIGRAVITY" + fs::write( + dir.path().join("shadow_upper.json"), + r#"{"id":"ANTIGRAVITY","label":"Fake Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + + // Case 3: mixed case id "AntiGravity" + fs::write( + dir.path().join("shadow_mixed.json"), + r#"{"id":"AntiGravity","label":"Fake Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + + // Case 4: whitespace in id (should fail validation) + fs::write( + dir.path().join("invalid_ws1.json"), + r#"{"id":" antigravity","label":"Fake Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + fs::write( + dir.path().join("invalid_ws2.json"), + r#"{"id":"antigravity ","label":"Fake Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + fs::write( + dir.path().join("invalid_ws3.json"), + r#"{"id":"anti gravity","label":"Fake Antigravity","command":"fake-agy"}"#, + ) + .unwrap(); + + // Case 5: a valid custom harness with a non-colliding ID + fs::write( + dir.path().join("valid_custom.json"), + r#"{"id":"my-antigravity-runner","label":"My Antigravity Runner","command":"my-runner"}"#, + ) + .unwrap(); + + // Case 6: a file NAMED antigravity_named.json but containing valid custom id + fs::write( + dir.path().join("antigravity_named.json"), + r#"{"id":"custom-runner","label":"Custom Runner","command":"custom-bin"}"#, + ) + .unwrap(); + + let loaded = load_custom_harnesses(dir.path()); + let loaded_ids: Vec<&str> = loaded.iter().map(|d| d.id.as_str()).collect(); + + assert_eq!( + loaded.len(), + 2, + "Only non-colliding valid definitions should load, got: {loaded_ids:?}" + ); + assert!(loaded_ids.contains(&"my-antigravity-runner")); + assert!(loaded_ids.contains(&"custom-runner")); + assert!(!loaded_ids.contains(&"antigravity")); + assert!(!loaded_ids.contains(&"ANTIGRAVITY")); + assert!(!loaded_ids.contains(&"AntiGravity")); +} + +// ============================================================================= +// 3. COMMAND NORMALIZATION & CASING STRESS TESTS +// ============================================================================= + +#[test] +fn stress_normalize_command_identity_all_permutations() { + let test_cases = [ + ("agy_acp_server", "agy-acp-server"), + ("agy_acp_server.par", "agy-acp-server"), + ("AGY_ACP_SERVER.PAR", "agy-acp-server"), + ("Agy_Acp_Server.Par", "agy-acp-server"), + ("agy_acp_server.exe", "agy-acp-server"), + ("AGY_ACP_SERVER.EXE", "agy-acp-server"), + ("agy-acp-server", "agy-acp-server"), + ("agy-acp-server.par", "agy-acp-server"), + ("google_antigravity", "google-antigravity"), + ("google-antigravity", "google-antigravity"), + ("GOOGLE_ANTIGRAVITY.PAR", "google-antigravity"), + ("antigravity", "antigravity"), + ("AntiGravity", "antigravity"), + ("ANTIGRAVITY", "antigravity"), + ("agy", "agy"), + ("AGY", "agy"), + // Path variations + ("/opt/google/bin/agy_acp_server.par", "agy-acp-server"), + ("/usr/local/bin/agy_acp_server", "agy-acp-server"), + (r"C:\Google\Bin\agy_acp_server.exe", "agy-acp-server"), + ("C:/Google/Bin/agy_acp_server.par", "agy-acp-server"), + ( + r"C:\Program Files\Google Antigravity\agy_acp_server.exe", + "agy-acp-server", + ), + // Whitespace handling + (" agy_acp_server.par ", "agy-acp-server"), + ("\tagy_acp_server.exe\n", "agy-acp-server"), + (" antigravity ", "antigravity"), + // Underscores and hyphens + ("agy__acp--server.par", "agy--acp--server"), + ("my_custom_agent.par", "my-custom-agent"), + ]; + + for (input, expected) in test_cases { + let normalized = normalize_command_identity(input); + assert_eq!( + normalized, expected, + "normalize_command_identity({input:?}) failed: got {normalized:?}, expected {expected:?}" + ); + } +} + +#[test] +fn stress_known_acp_runtime_lookup_permutations() { + let lookup_cases = [ + "antigravity", + "Antigravity", + "ANTIGRAVITY", + "google-antigravity", + "Google_Antigravity", + "GOOGLE_ANTIGRAVITY", + "agy", + "AGY", + ]; + + for query in lookup_cases { + let runtime = known_acp_runtime(query); + assert!( + runtime.is_some(), + "known_acp_runtime({query:?}) should resolve to antigravity" + ); + let rt = runtime.unwrap(); + assert_eq!( + rt.id, "antigravity", + "Resolved runtime ID for {query:?} must be 'antigravity', got: {:?}", + rt.id + ); + } +} + +// ============================================================================= +// 4. DEFAULT AGENT ARGS & LEGACY ACP NORMALIZATION TESTS +// ============================================================================= + +#[test] +fn stress_default_agent_args_and_normalization() { + // agy / antigravity (real CLI) -> empty on all platforms + for cmd in [ + "antigravity", + "Antigravity", + "agy", + "AGY", + "google-antigravity", + "/opt/bin/agy", + ] { + let args = default_agent_args(cmd); + assert!(args.is_some()); + assert_eq!( + args.unwrap(), + Vec::::new(), + "agy/antigravity must be empty for {cmd}" + ); + assert_eq!(normalize_agent_args(cmd, Vec::new()), Vec::::new()); + assert_eq!( + normalize_agent_args(cmd, vec![" ".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args(cmd, vec!["acp".into()]), + Vec::::new() + ); + let custom = vec!["--custom-flag".to_string(), "foo".to_string()]; + assert_eq!(normalize_agent_args(cmd, custom.clone()), custom); + } + // agy_acp_server spellings normalize to the same zero-arg identity. + for cmd in [ + "agy_acp_server", + "agy_acp_server.par", + "AGY_ACP_SERVER.PAR", + "/opt/bin/agy_acp_server.par", + r"C:\tools\agy_acp_server.exe", + ] { + assert_eq!(default_agent_args(cmd), Some(Vec::::new())); + } +} + +#[test] +fn stress_goose_preserves_acp_argument() { + let goose_args = normalize_agent_args("goose", vec!["acp".into()]); + assert_eq!( + goose_args, + vec!["acp"], + "Goose must preserve 'acp' argument" + ); +} diff --git a/desktop/src/features/onboarding/ui/HarnessMarks.tsx b/desktop/src/features/onboarding/ui/HarnessMarks.tsx index 48d2da4404a..e63291277a2 100644 --- a/desktop/src/features/onboarding/ui/HarnessMarks.tsx +++ b/desktop/src/features/onboarding/ui/HarnessMarks.tsx @@ -39,11 +39,28 @@ function CursorMark({ className }: MarkProps) { ); } +/// Antigravity mark from Google Antigravity (nominative use). +function AntigravityMark({ className }: MarkProps) { + return ( + + ); +} + /// Theme-adaptive inline marks, keyed by runtime/preset id. Consulted before /// the bitmap logo maps in `RuntimeIcon`. Codex deliberately has no entry: /// the OpenAI blossom was removed from simple-icons v16 at the vendor's /// request, so Codex renders RuntimeIcon's neutral terminal-glyph fallback. export const RUNTIME_MARKS: Record> = { + antigravity: AntigravityMark, cursor: CursorMark, goose: GooseMark, }; diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index aac9b53846a..dc3c883d6bb 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -476,6 +476,13 @@ function getOnboardingAuthMethods( return supported.slice(0, 1); } + if (runtime.id === "antigravity") { + const preferred = + supported.find((method) => method.id === "oauth-personal") ?? + supported[0]; + return preferred ? [preferred] : []; + } + return supported; } diff --git a/desktop/src/features/onboarding/ui/agentReadiness.ts b/desktop/src/features/onboarding/ui/agentReadiness.ts index 86b9721af35..04537b59bff 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.ts +++ b/desktop/src/features/onboarding/ui/agentReadiness.ts @@ -47,7 +47,9 @@ export function resolveAgentReadiness( } if ( - (preferredRuntime.id === "claude" || preferredRuntime.id === "codex") && + (preferredRuntime.id === "claude" || + preferredRuntime.id === "codex" || + preferredRuntime.id === "antigravity") && (preferredRuntime.authStatus.status === "logged_in" || preferredRuntime.authStatus.status === "not_applicable") ) { @@ -58,7 +60,11 @@ export function resolveAgentReadiness( }; } - if (preferredRuntime.id !== "buzz-agent" && preferredRuntime.id !== "goose") { + if ( + preferredRuntime.id !== "buzz-agent" && + preferredRuntime.id !== "goose" && + preferredRuntime.id !== "antigravity" + ) { return { ready: false }; } diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index 221702ebb2a..7b644ab6957 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -15,6 +15,7 @@ function runtime(id, availability, status) { test("all bundled harnesses are visible in onboarding", () => { assert.equal(runtimeIsVisibleInOnboarding("claude"), true); assert.equal(runtimeIsVisibleInOnboarding("codex"), true); + assert.equal(runtimeIsVisibleInOnboarding("antigravity"), true); assert.equal(runtimeIsVisibleInOnboarding("goose"), true); assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), true); assert.equal(runtimeIsVisibleInOnboarding("custom"), false); @@ -23,6 +24,7 @@ test("all bundled harnesses are visible in onboarding", () => { test("visible onboarding runtimes use the product order", () => { const runtimes = [ runtime("buzz-agent", "available", "not_applicable"), + runtime("antigravity", "available", "logged_in"), runtime("codex", "available", "logged_in"), runtime("goose", "available", "not_applicable"), runtime("claude", "available", "logged_in"), @@ -30,7 +32,7 @@ test("visible onboarding runtimes use the product order", () => { assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex", "goose", "buzz-agent"], + ["claude", "codex", "antigravity", "goose", "buzz-agent"], ); }); @@ -60,12 +62,13 @@ test("ready onboarding runtimes exclude unknown and non-ready harnesses", () => runtime("goose", "available", "not_applicable"), runtime("codex", "available", "logged_out"), runtime("buzz-agent", "available", "not_applicable"), + runtime("antigravity", "available", "logged_in"), runtime("claude", "available", "logged_in"), runtime("custom", "available", "not_applicable"), ]; assert.deepEqual( getReadyOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "goose", "buzz-agent"], + ["claude", "antigravity", "goose", "buzz-agent"], ); }); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index cd491dfcc5a..7adebfb277c 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -3,6 +3,7 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; export const ONBOARDING_RUNTIME_ORDER = [ "claude", "codex", + "antigravity", "goose", "buzz-agent", ];