diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2651318bc3..96f97126e0 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -498,7 +498,9 @@ const overrides = new Map([ // route PowerShell CLI installs natively on Windows (bypasses Git Bash PATH // poisoning that resolved GNU tar instead of bsdtar → Codex install failure). // Includes unit tests for detection, routing, and -Command body preservation. - ["src-tauri/src/commands/agent_discovery.rs", 1810], + // +16: test_powershell_command_goose_catalog_dequoted proves the \$→$ escape + // fix for the Goose Windows installer (PR #2680 interaction with #2750). + ["src-tauri/src/commands/agent_discovery.rs", 1826], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 05fb179125..f6094ed529 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -596,8 +596,6 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use super::*; use crate::managed_agents::{BackendKind, RespondTo}; @@ -614,7 +612,8 @@ mod tests { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, @@ -654,7 +653,7 @@ mod tests { parallelism: 1, system_prompt: None, model: None, - env_vars: BTreeMap::new(), + env_vars: Default::default(), start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, @@ -705,7 +704,7 @@ mod tests { is_active: true, source_team: None, source_team_persona_slug: None, - env_vars: BTreeMap::new(), + env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index f080272914..7b1979d654 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -12,6 +12,8 @@ use crate::{ relay::query_relay, }; +mod post_install_verification; + fn active_installs() -> &'static std::sync::Mutex> { use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; @@ -229,11 +231,10 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result>(), + vec![ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex", + ], + "Goose catalog command must dequote with bare $env: (no backslash before $)" + ); + } + // ── install retry ───────────────────────────────────────────────────────── /// Build an `InstallStepResult` with just the fields the retry loop reads. diff --git a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs new file mode 100644 index 0000000000..3155104b56 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs @@ -0,0 +1,74 @@ +use crate::managed_agents::{AcpAvailabilityStatus, InstallStepResult}; + +pub(super) fn run(runtime_id: &str, steps: &mut Vec) { + // Observe PATH changes and binaries added after Buzz launched. + crate::managed_agents::refresh_login_shell_path(); + crate::managed_agents::clear_resolve_cache(); + + let availability = crate::managed_agents::discover_acp_runtime_availability(runtime_id); + if let Some(failure) = failure(runtime_id, availability) { + steps.push(failure); + } +} + +fn failure( + runtime_id: &str, + availability: Option, +) -> Option { + if availability == Some(AcpAvailabilityStatus::Available) { + return None; + } + + let observed = availability + .map(availability_label) + .unwrap_or_else(|| "missing from the runtime catalog".to_string()); + Some(InstallStepResult { + step: "verify".to_string(), + command: format!("discover {runtime_id}"), + success: false, + stdout: String::new(), + stderr: format!( + "The installer finished, but Buzz still could not use {runtime_id} (observed: {observed})." + ), + exit_code: None, + hint: Some( + "Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again." + .to_string(), + ), + }) +} + +fn availability_label(status: AcpAvailabilityStatus) -> String { + match status { + AcpAvailabilityStatus::Available => "available", + AcpAvailabilityStatus::AdapterMissing => "ACP adapter missing", + AcpAvailabilityStatus::AdapterOutdated => "ACP adapter outdated", + AcpAvailabilityStatus::CliMissing => "CLI missing", + AcpAvailabilityStatus::NotInstalled => "not installed", + } + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_available_runtime() { + assert!(failure("goose", Some(AcpAvailabilityStatus::Available)).is_none()); + } + + #[test] + fn rejects_unresolved_runtime() { + let failure = failure("goose", Some(AcpAvailabilityStatus::NotInstalled)) + .expect("not-installed runtime must fail verification"); + + assert_eq!(failure.step, "verify"); + assert!(!failure.success); + assert!(failure.stderr.contains("observed: not installed")); + assert!(failure + .hint + .as_deref() + .is_some_and(|hint| hint.contains("desktop app"))); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index f5fc7c3899..4c11cd6c49 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -40,7 +40,8 @@ fn test_runtime() -> &'static KnownAcpRuntime { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, @@ -615,7 +616,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..ab76965259 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -73,10 +73,13 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("goose"), cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], - cli_install_commands_windows: &[], // goose install script is already Windows-aware + // Goose's stable release currently publishes only the Unix installer; + // its official Windows instructions intentionally point at this main-branch script. + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""], adapter_install_commands: &[], - install_instructions_url: "https://block.github.io/goose/", - cli_install_hint: "Install Goose via the official install script.", + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz requires the Goose CLI; the desktop app alone is not enough.", adapter_install_hint: "", skill_dir: Some(".goose/skills"), supports_acp_model_switching: false, @@ -106,8 +109,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], - install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Install the Claude Code CLI via the official install script.", + cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Buzz requires the Claude Code CLI; the desktop app alone is not enough.", adapter_install_hint: "Install the Claude Code ACP adapter via npm.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, @@ -137,8 +141,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], - install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Install the Codex CLI via the official install script.", + cli_install_instructions_url: "https://developers.openai.com/codex/cli/", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", + cli_install_hint: "Buzz requires the Codex CLI; the desktop app alone is not enough.", adapter_install_hint: "Install the Codex ACP adapter via npm.", skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, @@ -169,7 +174,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "https://github.com/block/buzz", + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", cli_install_hint: "Ships with the Buzz desktop app.", adapter_install_hint: "", skill_dir: None, @@ -1158,109 +1164,130 @@ struct PartialEntry { entry: AcpRuntimeCatalogEntry, } -pub fn discover_acp_runtimes() -> Vec { - // Phase 1: build all entries (fast — no probes yet). - let mut partials: Vec = KNOWN_ACP_RUNTIMES +fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry { + let adapter_result = runtime + .commands .iter() - .map(|runtime| { - let adapter_result = runtime - .commands - .iter() - .find_map(|command| find_command(command).map(|path| (*command, path))); - - let underlying_cli_found = runtime - .underlying_cli - .map(|cli| find_command(cli).is_some()) - .unwrap_or(false); - let (mut availability, command, binary_path) = - classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - - // For codex-acp: when the adapter resolves as Available, probe the - // version. An adapter with major version < 1 is treated as outdated — - // the CODEX_CONFIG spawn contract requires 1.x. - if runtime.id == "codex" - && availability == AcpAvailabilityStatus::Available - && command.as_deref() == Some("codex-acp") - { - if let Some(path_str) = &binary_path { - availability = codex_adapter_availability(&PathBuf::from(path_str)); - } - } + .find_map(|command| find_command(command).map(|path| (*command, path))); + + let underlying_cli_found = runtime + .underlying_cli + .map(|cli| find_command(cli).is_some()) + .unwrap_or(false); + let (mut availability, command, binary_path) = + classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); + + // For codex-acp: when the adapter resolves as Available, probe the + // version. An adapter with major version < 1 is treated as outdated — + // the CODEX_CONFIG spawn contract requires 1.x. + if runtime.id == "codex" + && availability == AcpAvailabilityStatus::Available + && command.as_deref() == Some("codex-acp") + { + if let Some(path_str) = &binary_path { + availability = codex_adapter_availability(&PathBuf::from(path_str)); + } + } - // Warm the adapter-availability cache for the badge fallback. - // The cache is scoped to the codex runtime; other runtimes leave it - // unchanged. Invalidated by `clear_resolve_cache`. - if runtime.id == "codex" { - cache_adapter_availability(availability.clone()); - } + // Warm the adapter-availability cache for the badge fallback. + // The cache is scoped to the codex runtime; other runtimes leave it + // unchanged. Invalidated by `clear_resolve_cache`. + if runtime.id == "codex" { + cache_adapter_availability(availability.clone()); + } - let underlying_cli_path = runtime - .underlying_cli - .and_then(find_command) - .map(|p| p.display().to_string()); - - let default_args = command - .as_deref() - .map(|cmd| normalize_agent_args(cmd, Vec::new())) - .unwrap_or_default(); - - let can_auto_install = !runtime.cli_install_commands_for_os().is_empty() - || !runtime.adapter_install_commands.is_empty(); - - let cli_hint = runtime.cli_install_hint; - let adapter_hint = runtime.adapter_install_hint; - let install_hint = match availability { - AcpAvailabilityStatus::Available => cli_hint.to_string(), - AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), - AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), - AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), - AcpAvailabilityStatus::NotInstalled => { - if !cli_hint.is_empty() && !adapter_hint.is_empty() { - format!("{cli_hint} {adapter_hint}") - } else if !cli_hint.is_empty() { - cli_hint.to_string() - } else { - adapter_hint.to_string() - } - } - }; + let underlying_cli_path = runtime + .underlying_cli + .and_then(find_command) + .map(|p| p.display().to_string()); - // node_required now means Buzz cannot provide npm for this platform. - // On supported desktop platforms, Buzz downloads a private Node/npm - // runtime into app data before running npm-backed adapter installs. - let node_required = matches!( - availability, - AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled - ) && runtime_needs_npm(runtime) - && buzz_managed_node_bin_dir().is_none() - && resolve_command("npm").is_none() - && resolve_command("node").is_none(); - - PartialEntry { - runtime, - entry: AcpRuntimeCatalogEntry { - id: runtime.id.to_string(), - label: runtime.label.to_string(), - avatar_url: runtime.avatar_url.to_string(), - availability, - command, - binary_path, - default_args, - mcp_command: runtime.mcp_command.map(str::to_string), - model_env_var: runtime.model_env_var.map(str::to_string), - provider_env_var: runtime.provider_env_var.map(str::to_string), - thinking_env_var: runtime.thinking_env_var.map(str::to_string), - install_hint, - install_instructions_url: runtime.install_instructions_url.to_string(), - can_auto_install, - underlying_cli_path, - node_required, - // Filled in by the probe phase below. - auth_status: AuthStatus::Unknown, - login_hint: None, - }, + let default_args = command + .as_deref() + .map(|cmd| normalize_agent_args(cmd, Vec::new())) + .unwrap_or_default(); + + let can_auto_install = !runtime.cli_install_commands_for_os().is_empty() + || !runtime.adapter_install_commands.is_empty(); + + let cli_hint = runtime.cli_install_hint; + let adapter_hint = runtime.adapter_install_hint; + let install_hint = match availability { + AcpAvailabilityStatus::Available => cli_hint.to_string(), + AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), + AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), + AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), + AcpAvailabilityStatus::NotInstalled => { + if !cli_hint.is_empty() && !adapter_hint.is_empty() { + format!("{cli_hint} {adapter_hint}") + } else if !cli_hint.is_empty() { + cli_hint.to_string() + } else { + adapter_hint.to_string() } - }) + } + }; + let install_instructions_url = match availability { + AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::AdapterOutdated => { + runtime.adapter_install_instructions_url + } + AcpAvailabilityStatus::Available + | AcpAvailabilityStatus::CliMissing + | AcpAvailabilityStatus::NotInstalled => runtime.cli_install_instructions_url, + }; + + // node_required now means Buzz cannot provide npm for this platform. + // On supported desktop platforms, Buzz downloads a private Node/npm + // runtime into app data before running npm-backed adapter installs. + let node_required = matches!( + availability, + AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled + ) && runtime_needs_npm(runtime) + && buzz_managed_node_bin_dir().is_none() + && resolve_command("npm").is_none() + && resolve_command("node").is_none(); + + PartialEntry { + runtime, + entry: AcpRuntimeCatalogEntry { + id: runtime.id.to_string(), + label: runtime.label.to_string(), + avatar_url: runtime.avatar_url.to_string(), + availability, + command, + binary_path, + default_args, + mcp_command: runtime.mcp_command.map(str::to_string), + model_env_var: runtime.model_env_var.map(str::to_string), + provider_env_var: runtime.provider_env_var.map(str::to_string), + thinking_env_var: runtime.thinking_env_var.map(str::to_string), + install_hint, + install_instructions_url: install_instructions_url.to_string(), + can_auto_install, + requires_external_cli: runtime.underlying_cli.is_some(), + underlying_cli_path, + node_required, + // Filled in by the auth-probe phase in full catalog discovery. + auth_status: AuthStatus::Unknown, + login_hint: None, + }, + } +} + +/// Discover one runtime's filesystem availability without running any auth probes. +/// +/// Post-install verification only needs to know whether the requested runtime +/// resolves, so it should not pay the cost of authenticating every catalog entry. +pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option { + known_acp_runtime_exact(runtime_id) + .map(discover_acp_runtime_phase1) + .map(|partial| partial.entry.availability) +} + +pub fn discover_acp_runtimes() -> Vec { + // Phase 1: build all entries (fast — no probes yet). + let mut partials: Vec = KNOWN_ACP_RUNTIMES + .iter() + .map(discover_acp_runtime_phase1) .collect(); // Phase 2: run auth probes in parallel for entries that need them. diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 4dfcad4256..2fb6a471d4 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -20,8 +20,10 @@ pub(crate) struct KnownAcpRuntime { pub cli_install_commands_windows: &'static [&'static str], /// Shell commands to install the ACP adapter (run sequentially, after CLI). pub adapter_install_commands: &'static [&'static str], - /// Link to docs/repo for manual instructions. - pub install_instructions_url: &'static str, + /// Official CLI installation documentation. + pub cli_install_instructions_url: &'static str, + /// ACP adapter installation documentation. + pub adapter_install_instructions_url: &'static str, /// Human-readable hint about installing the CLI binary. pub cli_install_hint: &'static str, /// Human-readable hint about installing the ACP adapter. @@ -78,3 +80,45 @@ impl KnownAcpRuntime { self.cli_install_commands } } + +#[cfg(test)] +mod tests { + use super::super::known_acp_runtime_exact; + + #[test] + fn vendor_metadata_distinguishes_cli_and_adapter_guidance() { + let goose = known_acp_runtime_exact("goose").unwrap(); + assert_eq!( + goose.cli_install_instructions_url, + "https://goose-docs.ai/docs/getting-started/installation/" + ); + assert!(goose.adapter_install_instructions_url.is_empty()); + assert!(goose.cli_install_hint.contains("desktop app alone")); + assert!(goose + .cli_install_commands_windows + .iter() + .any(|command| command.contains("raw.githubusercontent.com/aaif-goose/goose/main"))); + assert!(goose + .cli_install_commands_windows + .iter() + .any(|command| command.contains("$env:CONFIGURE='false'"))); + + let claude = known_acp_runtime_exact("claude").unwrap(); + assert_eq!( + claude.cli_install_instructions_url, + "https://code.claude.com/docs/en/getting-started" + ); + assert!(claude + .adapter_install_instructions_url + .contains("claude-agent-acp")); + assert!(claude.cli_install_hint.contains("desktop app alone")); + + let codex = known_acp_runtime_exact("codex").unwrap(); + assert_eq!( + codex.cli_install_instructions_url, + "https://developers.openai.com/codex/cli/" + ); + assert!(codex.adapter_install_instructions_url.contains("codex-acp")); + assert!(codex.cli_install_hint.contains("desktop app alone")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..364caa452b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1104,16 +1104,18 @@ fn test_cli_install_commands_for_os_non_empty_for_claude_codex() { ); } -/// On Windows, Claude and Codex select the PowerShell install commands. +/// On Windows, every vendor CLI selects its official PowerShell installer. #[cfg(windows)] #[test] fn test_cli_install_commands_for_os_selects_powershell_on_windows() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); + let goose = super::known_acp_runtime_exact("goose").unwrap(); // Windows must select the PowerShell commands, not the curl|bash ones. let claude_cmds = claude.cli_install_commands_for_os(); let codex_cmds = codex.cli_install_commands_for_os(); + let goose_cmds = goose.cli_install_commands_for_os(); assert_ne!( claude_cmds, claude.cli_install_commands, @@ -1123,6 +1125,7 @@ fn test_cli_install_commands_for_os_selects_powershell_on_windows() { codex_cmds, codex.cli_install_commands, "Windows must NOT use the default curl|bash commands for codex" ); + assert_eq!(goose_cmds, goose.cli_install_commands_windows); // Verify they are the PowerShell installers. assert!( @@ -1133,13 +1136,9 @@ fn test_cli_install_commands_for_os_selects_powershell_on_windows() { codex_cmds.iter().any(|c| c.contains("powershell")), "codex Windows install must use powershell; got: {codex_cmds:?}" ); - - // Goose and buzz-agent must NOT use Windows-specific commands. - let goose = super::known_acp_runtime_exact("goose").unwrap(); - assert_eq!( - goose.cli_install_commands_for_os(), - goose.cli_install_commands, - "goose must use the same commands on all platforms" + assert!( + goose_cmds.iter().any(|c| c.contains("download_cli.ps1")), + "Goose Windows install must use download_cli.ps1; got: {goose_cmds:?}" ); } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..c04dfa88a4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -869,7 +869,8 @@ mod tests { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, @@ -1063,7 +1064,8 @@ mod tests { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 90da759af0..af4908911b 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -585,6 +585,8 @@ pub struct AcpRuntimeCatalogEntry { pub install_instructions_url: String, /// true when at least one automated install step is available pub can_auto_install: bool, + /// true when this runtime depends on a separately installed vendor CLI. + pub requires_external_cli: bool, pub underlying_cli_path: Option, /// true when an npm adapter step is pending but Node.js / npm is absent. /// The UI hides the Install button and shows a Node.js install callout. diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index dc61984487..2af9ddb98c 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -15,7 +15,9 @@ Plan of record: `Buzz/Harness-Provider-Model.md` in Morgan's Obsidian vault declares each harness's model/provider/effort env keys and capabilities. Spawn applies them; `AcpRuntimeCatalogEntry` exposes them over IPC; and `lib/agentConfigCore.ts` projects them into field descriptors. The frontend -never maintains a rival copy of this table. +never maintains a rival copy of this table. Setup guidance follows the same +rule: `requires_external_cli` is derived from `KnownAcpRuntime` and projected +to the UI rather than inferred from a runtime ID in a component. If you need a new capability fact (a new env key, a native option, a "supports X" flag): add it to `KnownAcpRuntime` first, expose it on diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 7556a79ba2..5263920854 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -627,8 +627,8 @@ export function AgentDefinitionDialog({ ? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.` : selectedRuntime.availability === "adapter_outdated" ? `${selectedRuntime.label} ACP adapter is outdated — reinstall to continue.` - : selectedRuntime.availability === "cli_missing" - ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` + : selectedRuntime.requiresExternalCli + ? `${selectedRuntime.label} CLI is missing. ${selectedRuntime.installHint}` : `${selectedRuntime.label} is not installed.`}{" "} Visit Settings > Agents to set it up.

diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index aa02486706..288ca30259 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -384,8 +384,11 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string { if (runtime.availability === "adapter_outdated") { return "ACP adapter detected but outdated — reinstall required."; } - if (runtime.availability === "cli_missing") { - return "ACP adapter detected; CLI missing."; + if ( + runtime.availability === "cli_missing" || + runtime.availability === "not_installed" + ) { + return "CLI not detected; the desktop app alone isn’t enough."; } return ""; } @@ -605,8 +608,8 @@ function RuntimeProvidersSection({ Set up your agent harnesses

- Buzz detected the harnesses available on this machine. Install or sign - in to at least one to continue. + Buzz checks for command-line harnesses on this machine. Install the + CLI or sign in to at least one to continue.

@@ -629,8 +632,8 @@ function RuntimeProvidersSection({ className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground" data-testid="onboarding-acp-empty" > - No supported agent harnesses were detected yet. Install Claude Code - or Codex, then check again. + No supported command-line harnesses were detected yet. Install a + supported CLI, then check again.

)} diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 7a0186a7ba..894d88bd22 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -53,6 +53,13 @@ const RUNTIME_SORT_PRIORITY: Record = { goose: 1, }; +function runtimeInstallGuideLabel(runtime: AcpRuntimeCatalogEntry) { + return runtime.availability === "adapter_missing" || + runtime.availability === "adapter_outdated" + ? "Adapter install guide" + : "CLI setup guide"; +} + function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { const avatarUrl = RUNTIME_LOGO_URLS[runtime.id] ?? runtime.avatarUrl; @@ -131,7 +138,7 @@ function RuntimeOverflowMenu({ onSelect={() => void openUrl(runtime.installInstructionsUrl)} > - Instructions + {runtimeInstallGuideLabel(runtime)} ) : null} @@ -142,7 +149,6 @@ function RuntimeOverflowMenu({ function RuntimeActions({ authMethods, connectingMethodId, - installSuccess, isConnecting, isInstalling, onConnect, @@ -151,7 +157,6 @@ function RuntimeActions({ }: { authMethods: AcpAuthMethod[]; connectingMethodId: string | null; - installSuccess: boolean; isConnecting: boolean; isInstalling: boolean; onConnect: (method: AcpAuthMethod) => void; @@ -160,7 +165,6 @@ function RuntimeActions({ }) { const isAvailable = runtime.availability === "available"; const canInstall = runtime.canAutoInstall && !runtime.nodeRequired; - const isOn = isAvailable || installSuccess; const isWorking = isInstalling || isConnecting; return ( @@ -183,10 +187,10 @@ function RuntimeActions({ ) : ( { if (checked) { onInstall(); @@ -206,7 +210,8 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ? "Adapter needed" : runtime.availability === "adapter_outdated" ? "Update needed" - : runtime.availability === "cli_missing" + : runtime.availability === "cli_missing" || + runtime.availability === "not_installed" ? "CLI needed" : null; @@ -239,7 +244,6 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { function RuntimeHeader({ authMethods, connectingMethodId, - installSuccess, isConnecting, isInstalling, onConnect, @@ -248,7 +252,6 @@ function RuntimeHeader({ }: { authMethods: AcpAuthMethod[]; connectingMethodId: string | null; - installSuccess: boolean; isConnecting: boolean; isInstalling: boolean; onConnect: (method: AcpAuthMethod) => void; @@ -267,7 +270,6 @@ function RuntimeHeader({ { @@ -391,6 +391,25 @@ function RuntimeRow({ runtime={runtime} /> + {runtime.availability !== "available" ? ( +
+

{runtime.installHint}

+ {runtime.installInstructionsUrl.trim().length > 0 ? ( + + ) : null} +
+ ) : null} + {runtime.authStatus.status === "config_invalid" ? (

) : null} - {installSuccess && runtime.availability !== "available" ? ( -

- {runtime.label} installed. Checking for sign-in options... -

- ) : null} {installError ? (

{ ), ), ); - expect(new Set(rowHeights).size).toBe(1); + expect(rowHeights[2]).toBeGreaterThan(rowHeights[0]); const [gooseColors, codexColors] = await Promise.all( ["goose", "codex"].map((runtimeId) => page.getByTestId(`doctor-runtime-${runtimeId}`).evaluate((element) => { @@ -194,11 +196,11 @@ test.describe("Doctor panel state screenshots", () => { await expect(toggle.locator("span")).toHaveClass(/shadow-none/); } await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toHaveCount(0); await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await waitForAnimations(page); await page.screenshot({ @@ -212,6 +214,17 @@ test.describe("Doctor panel state screenshots", () => { await expect(page.getByTestId("doctor-runtime-codex")).not.toContainText( "Not installed", ); + await expect(page.getByTestId("doctor-runtime-status-codex")).toHaveText( + "CLI needed", + ); + await expect( + page.getByTestId("doctor-runtime-guidance-codex"), + ).toContainText("desktop app alone is not enough"); + await expect( + page + .getByTestId("doctor-runtime-guidance-codex") + .getByRole("button", { name: "CLI setup guide" }), + ).toBeVisible(); await runtimeList.scrollIntoViewIfNeeded(); await waitForAnimations(page); @@ -287,7 +300,7 @@ test.describe("Doctor panel state screenshots", () => { ); await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await page.keyboard.press("Escape"); @@ -331,7 +344,7 @@ test.describe("Doctor panel state screenshots", () => { ); await page.getByTestId("doctor-runtime-menu-claude").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await page.keyboard.press("Escape"); @@ -354,8 +367,9 @@ test.describe("Doctor panel state screenshots", () => { availability: "adapter_missing", underlying_cli_path: "/usr/local/bin/codex", node_required: true, - install_hint: - "Install the Codex ACP adapter: npm install -g @zed-industries/codex-acp", + install_hint: "Install the Codex ACP adapter via npm.", + install_instructions_url: + "https://github.com/agentclientprotocol/codex-acp", }, BUZZ_AGENT_AVAILABLE, ], @@ -373,16 +387,20 @@ test.describe("Doctor panel state screenshots", () => { "Adapter needed", ); await expect(row).not.toContainText("Node.js is required"); - await expect(row).toHaveCSS( - "height", + expect( + await row.evaluate((element) => element.getBoundingClientRect().height), + ).toBeGreaterThan( await page .getByTestId("doctor-runtime-goose") - .evaluate((element) => getComputedStyle(element).height), + .evaluate((element) => element.getBoundingClientRect().height), ); await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( page.getByRole("menuitem", { name: "Install Node.js" }), ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Adapter install guide" }), + ).toBeVisible(); await page.keyboard.press("Escape"); await row.scrollIntoViewIfNeeded(); @@ -395,9 +413,10 @@ test.describe("Doctor panel state screenshots", () => { * * The mock is configured with a two-call sequence: * call 1 → failure (E404) - * call 2 → success + * call 2 → installer exit 0, but post-install discovery still cannot find + * the runtime * This exercises the full retry path: fail state → toggle on again → - * success banner. + * verified failure without a false installed state. */ test("05-retry-after-failure", async ({ page }) => { await installMockBridge(page, { @@ -427,16 +446,26 @@ test.describe("Doctor panel state screenshots", () => { ], }, { - success: true, + success: false, steps: [ { - step: "adapter", - command: "npm install -g @zed-industries/codex-acp", + step: "cli", + command: "powershell.exe install codex", success: true, - stdout: "added 1 package", + stdout: "installed", stderr: "", exit_code: 0, }, + { + step: "verify", + command: "discover codex", + success: false, + stdout: "", + stderr: + "The installer finished, but Buzz still could not use codex (observed: NotInstalled).", + exit_code: null, + hint: "Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again.", + }, ], }, ], @@ -470,25 +499,71 @@ test.describe("Doctor panel state screenshots", () => { await waitForAnimations(page); await row.screenshot({ path: `${SHOTS}/05-retry-after-failure.png` }); - // Toggle on again — the mock returns success on the second call. + // Toggle on again — the install command exits 0, but verification fails. await toggle.click(); await expect(loading).toBeVisible(); await expect(toggle).toHaveCount(0); - // The error disappears, then the success banner and on state render. + // The runtime remains retryable and never renders a false success state. await expect(loading).toHaveCount(0, { timeout: 5_000 }); - await expect(row).not.toContainText("failed", { timeout: 5_000 }); - await expect( - row.getByText("Codex installed. Checking for sign-in options..."), - ).toBeVisible({ - timeout: 10_000, - }); - await expect(toggle).toBeChecked(); - await expect(toggle).toBeDisabled(); + await expect(row).toContainText("desktop app", { timeout: 5_000 }); + await expect(row).toContainText('Step "verify" failed'); + await expect(row.getByText(/installed\. Checking/)).toHaveCount(0); + await expect(toggle).not.toBeChecked(); + await expect(toggle).toBeEnabled(); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); - await row.screenshot({ path: `${SHOTS}/05-retry-success.png` }); + await row.screenshot({ path: `${SHOTS}/05-verification-failure.png` }); + }); + + test("05b-verified-install-enables-runtime", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + acpRuntimesCatalogAfterInstall: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + availability: "available", + command: "codex-acp", + binary_path: "/usr/local/bin/codex-acp", + underlying_cli_path: "/usr/local/bin/codex", + auth_status: { status: "logged_in" }, + }, + BUZZ_AGENT_AVAILABLE, + ], + installAcpRuntimeResult: { + success: true, + steps: [ + { + step: "adapter", + command: "npm install -g @agentclientprotocol/codex-acp", + success: true, + stdout: "added 1 package", + stderr: "", + exit_code: 0, + }, + ], + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const toggle = page.getByTestId("doctor-runtime-toggle-codex"); + await expect(toggle).toBeEnabled(); + await toggle.click(); + await expect(toggle).toBeChecked({ timeout: 5_000 }); + await expect(toggle).toBeDisabled(); + await expect(page.getByTestId("doctor-runtime-guidance-codex")).toHaveCount( + 0, + ); }); /** @@ -590,7 +665,7 @@ test.describe("Doctor panel state screenshots", () => { ); await page.getByTestId("doctor-runtime-menu-claude").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await expect( page.getByRole("menuitem", { name: "Sign in with ChatGPT" }), diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index e1d8b11b6f..d007bd6118 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -84,6 +84,40 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await expect(page.getByRole("checkbox")).toHaveCount(0); }); +test("setup distinguishes a missing CLI from an installed desktop app", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime( + "codex", + "not_installed", + { status: "unknown" }, + { + install_hint: + "Buzz requires the Codex CLI; the desktop app alone is not enough.", + install_instructions_url: + "https://developers.openai.com/codex/cli/", + }, + ), + ], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + const card = page.getByTestId("onboarding-runtime-codex"); + await expect(card).toContainText( + "CLI not detected; the desktop app alone isn’t enough.", + ); + await expect(card.getByTestId("onboarding-runtime-install-codex")).toHaveText( + "INSTALL", + ); +}); + test("ready state is detected and enables Next without persisting a default", async ({ page, }) => {