diff --git a/TESTING.md b/TESTING.md index 51a5eb44c1..3790fd471c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -179,8 +179,8 @@ header fallback. There is no REST API for fetching message threads — use ## ACP Harness (optional, end-to-end with a real agent) -`buzz-acp` connects an ACP-speaking agent (goose, codex, claude code, -buzz-agent) to the relay. The harness listens for events, drives the +`buzz-acp` connects an ACP-speaking agent (goose, grok build, codex, +claude code, buzz-agent) to the relay. The harness listens for events, drives the agent over stdio, and the agent replies through MCP tools. Minimum recipe — assumes the relay from step 3 is running and the channel @@ -221,10 +221,12 @@ buzz-acp # foreground; logs to stdout (run in ``` > **Using a different ACP agent?** The default recipe assumes `goose` is on -> `$PATH` and configured (`goose --version` should print). For codex / claude -> code / buzz-agent, set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS` -> accordingly — see `crates/buzz-acp/README.md`. Without these, buzz-acp -> will fail to spawn the agent subprocess on startup. +> `$PATH` and configured (`goose --version` should print). For grok build / +> codex / claude code / buzz-agent, set `BUZZ_ACP_AGENT_COMMAND` and +> `BUZZ_ACP_AGENT_ARGS` accordingly — see `crates/buzz-acp/README.md` +> (grok build: `BUZZ_ACP_AGENT_COMMAND=grok` with args +> `agent --always-approve stdio`). Without these, buzz-acp will fail to +> spawn the agent subprocess on startup. If you started the agent before adding it to the channel, just run the `add-member` afterwards — it picks up the membership notification live and diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..1e3d274035 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**, **grok build** (native, via `grok agent stdio`), **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). ## Prerequisites diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..4d3ae369fc 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -617,6 +617,14 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + // `--always-approve` is intentional: Buzz owns the approval layer for + // managed agents, so Grok-side tool prompts would deadlock a headless + // stdio session. + "grok" | "grok-build" => Some(vec![ + "agent".to_string(), + "--always-approve".to_string(), + "stdio".to_string(), + ]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -1440,6 +1448,19 @@ mod tests { assert_eq!(normalize_agent_args("goose", vec!["".into()]), vec!["acp"]); } + #[test] + fn normalizes_grok_args_to_stdio_default() { + let expected = vec!["agent", "--always-approve", "stdio"]; + assert_eq!(normalize_agent_args("grok", Vec::new()), expected); + assert_eq!(normalize_agent_args("grok", vec!["".into()]), expected); + assert_eq!(normalize_agent_args("grok-build", Vec::new()), expected); + // Explicit args are preserved verbatim — no default-arg injection. + assert_eq!( + normalize_agent_args("grok", vec!["agent".into(), "stdio".into()]), + vec!["agent", "stdio"] + ); + } + #[test] fn normalizes_codex_and_claude_args_to_empty() { assert_eq!( diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0230ea0875..2de239cfba 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -60,7 +60,12 @@ fn is_subcommand(name: &str) -> bool { } /// Timeout for lightweight helper subcommands (spawn + initialize + model/method probes). -const MODELS_TIMEOUT: Duration = Duration::from_secs(10); +/// +/// 30s rather than 10s: some agents boot every user-configured MCP server +/// during `session/new` (observed with Grok Build, where a handful of stdio +/// MCP servers pushed session creation past 10s on a real machine). The +/// happy path is unaffected — fast agents still return in ~2-5s. +const MODELS_TIMEOUT: Duration = Duration::from_secs(30); /// Timeout for `buzz-acp authenticate`. Browser-based vendor auth can require /// human interaction, so it must not share the short probe timeout. diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2b7264b429..d370ad8b2d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -17,6 +17,7 @@ pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +const GROK_AVATAR_URL: &str = "https://x.ai/icon.png"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; @@ -41,6 +42,7 @@ fn common_binary_paths() -> &'static [PathBuf] { home.join(".local/bin"), home.join(".volta/bin"), home.join(".asdf/shims"), + home.join(".grok/bin"), ]); } // Windows well-known dirs for npm global shims and standalone installer targets. @@ -163,6 +165,43 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + KnownAcpRuntime { + id: "grok", + label: "Grok Build", + commands: &["grok"], + aliases: &["grok-build"], + avatar_url: GROK_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("grok"), + cli_install_commands: &["curl -fsSL https://x.ai/cli/install.sh | bash"], + // No official PowerShell installer documented yet; Windows users get + // the Unix command as a hint alongside the instructions URL. + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://x.ai/cli", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz requires the Grok CLI; `grok agent stdio` provides the ACP server.", + adapter_install_hint: "", + skill_dir: Some(".grok/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, + required_normalized_fields: &[], + login_hint: Some("Run `grok login` (or set XAI_API_KEY) to authenticate."), + // Grok 0.2.x has no verified non-interactive auth-status command + // (`grok models` exits 0 when logged in, but the logged-out exit code + // is unverified), so the probe is intentionally omitted. + auth_probe_args: None, + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -457,6 +496,14 @@ pub fn try_record_agent_command( fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + // `--always-approve` is intentional: Buzz owns the approval layer for + // managed agents, so Grok-side tool prompts would deadlock a headless + // stdio session. + "grok" | "grok-build" => Some(vec![ + "agent".to_string(), + "--always-approve".to_string(), + "stdio".to_string(), + ]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 48e8d5479c..a9dff88983 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -8,7 +8,7 @@ use super::{ is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, preset_catalog_entry, probe_codex_acp_major_version, record_agent_command, refresh_login_shell_path, try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL, - CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, GROK_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -72,6 +72,44 @@ fn normalizes_claude_and_codex_args_to_empty() { ); } +#[test] +fn normalizes_grok_args_to_stdio_default() { + let expected = vec![ + "agent".to_string(), + "--always-approve".to_string(), + "stdio".to_string(), + ]; + // Empty args resolve to the full agent-mode invocation. + assert_eq!(normalize_agent_args("grok", Vec::new()), expected); + // Path and alias forms normalize to the same identity. + assert_eq!( + normalize_agent_args("/home/dev/.grok/bin/grok", Vec::new()), + expected + ); + assert_eq!(normalize_agent_args("grok-build", Vec::new()), expected); + // Explicit args are preserved verbatim — no default-arg injection. + assert_eq!( + normalize_agent_args("grok", vec!["agent".into(), "stdio".into()]), + vec!["agent".to_string(), "stdio".to_string()] + ); +} + +#[test] +fn resolves_grok_avatar() { + assert_eq!( + managed_agent_avatar_url("grok"), + Some(GROK_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/home/dev/.grok/bin/grok"), + Some(GROK_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("grok-build"), + Some(GROK_AVATAR_URL.to_string()) + ); +} + #[test] fn resolves_buzz_agent_avatar() { assert_eq!( diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs index 967bd3e129..9bb8b77add 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs +++ b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs @@ -195,6 +195,7 @@ test("resolveRuntimeProviderCapability classifies known CLI-login runtimes as lo // The core fix: a not-yet-loaded catalog must not force these to "unknown". assert.equal(resolveRuntimeProviderCapability("claude", false), "locked"); assert.equal(resolveRuntimeProviderCapability("codex", false), "locked"); + assert.equal(resolveRuntimeProviderCapability("grok", false), "locked"); assert.equal(resolveRuntimeProviderCapability(" claude ", false), "locked"); }); diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index 20d789c4eb..0549496293 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -18,7 +18,8 @@ export type ProviderRuntimeCapability = "capable" | "locked" | "unknown"; * provider. To avoid that, we resolve capability STATICALLY for known ids: * * - buzz-agent / goose → "capable" (`isProviderCapable`, id-based). - * - claude / codex → "locked" (CLI-login runtimes; no LLM provider selection). + * - claude / codex / grok → "locked" (CLI-login runtimes; no LLM provider + * selection). * - anything else (custom, empty, genuinely unknown) → "unknown". * * `isProviderCapable` is the caller-supplied {@link @@ -33,7 +34,7 @@ export function resolveRuntimeProviderCapability( return "capable"; } const id = runtimeId.trim(); - if (id === "claude" || id === "codex") { + if (id === "claude" || id === "codex" || id === "grok") { return "locked"; } return "unknown";