mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): clarify CLI runtime setup (#2680)
## Why Installing the Codex, Claude, or Goose desktop app does not install the command-line harness Buzz needs. The current UI makes that distinction unclear, links some missing-CLI states to adapter documentation, and can report a successful install from the installer exit code even when runtime discovery still fails. On Windows, Buzz also invokes Goose's Bash installer, which writes the executable somewhere Buzz does not discover. ## What - distinguish missing vendor CLIs from missing or outdated ACP adapters in runtime metadata and UI guidance - link Codex, Claude Code, and Goose missing-CLI states to their official CLI installation documentation - explain in Settings, onboarding, and agent configuration that the desktop app alone is not sufficient - use Goose's official PowerShell installer on Windows - refresh PATH and rediscover the requested runtime after installation, keeping the control retryable if the runtime is still unavailable - add Rust and Playwright regression coverage for Windows installer selection, CLI/adapter guidance, false-success prevention, verified installs, and onboarding copy ## Risk Assessment Medium. This changes desktop onboarding and runtime installation behavior. Successful installs now require the runtime catalog to verify availability; previously hidden discovery failures will surface as actionable errors instead of a false success state. ## References - [Codex CLI installation](https://developers.openai.com/codex/cli/) - [Claude Code installation](https://code.claude.com/docs/en/getting-started) - [Goose installation](https://goose-docs.ai/docs/getting-started/installation/) - Follow-up to #2563 and #2587 ## Validation - `just desktop-typecheck` - `just desktop-test` — 3,455 passed - focused Rust post-install verification tests - focused Playwright Doctor/onboarding coverage (in progress; CI and local sequential rerun will provide final results) Generated with Codex --------- Signed-off-by: Atish Patel <atish@squareup.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Goose <opensource@block.xyz>
This commit is contained in:
co-authored by
Codex
Goose
parent
8398468ec9
commit
b8510ede1b
@@ -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
|
||||
|
||||
@@ -596,8 +596,6 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, 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,
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::{
|
||||
relay::query_relay,
|
||||
};
|
||||
|
||||
mod post_install_verification;
|
||||
|
||||
fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -229,11 +231,10 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the resolve cache so the next discovery picks up new binaries.
|
||||
crate::managed_agents::clear_resolve_cache();
|
||||
post_install_verification::run(runtime_id, &mut steps);
|
||||
|
||||
Ok(InstallRuntimeResult {
|
||||
success: true,
|
||||
success: steps.iter().all(|step| step.success),
|
||||
steps,
|
||||
restarted_count: 0,
|
||||
failed_restart_count: 0,
|
||||
@@ -1500,17 +1501,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Goose install commands are the same on all platforms (script is Windows-aware).
|
||||
#[test]
|
||||
fn test_goose_install_commands_same_on_all_platforms() {
|
||||
let goose = crate::managed_agents::known_acp_runtime_exact("goose").unwrap();
|
||||
assert_eq!(
|
||||
goose.cli_install_commands_for_os(),
|
||||
goose.cli_install_commands,
|
||||
"goose install commands must be identical across platforms"
|
||||
);
|
||||
}
|
||||
|
||||
/// buzz-agent has no install commands on any platform.
|
||||
#[test]
|
||||
fn test_buzz_agent_has_no_install_commands() {
|
||||
@@ -1675,6 +1665,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Goose Windows catalog command (discovery.rs:78) must dequote to a bare pipeline
|
||||
/// with a literal `$env:` prefix — no backslash before the dollar sign.
|
||||
/// This proves the `\$` → `$` escape fix: post-#2750 the spawn is native and
|
||||
/// PowerShell receives the body verbatim, so a residual `\` would produce
|
||||
/// `\$env:CONFIGURE='false'` which is a malformed statement.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_powershell_command_goose_catalog_dequoted() {
|
||||
let cmd = super::install_powershell_command(
|
||||
r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex""#,
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::managed_agents::{AcpAvailabilityStatus, InstallStepResult};
|
||||
|
||||
pub(super) fn run(runtime_id: &str, steps: &mut Vec<InstallStepResult>) {
|
||||
// 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<AcpAvailabilityStatus>,
|
||||
) -> Option<InstallStepResult> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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<AcpAvailabilityStatus> {
|
||||
known_acp_runtime_exact(runtime_id)
|
||||
.map(discover_acp_runtime_phase1)
|
||||
.map(|partial| partial.entry.availability)
|
||||
}
|
||||
|
||||
pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
|
||||
// Phase 1: build all entries (fast — no probes yet).
|
||||
let mut partials: Vec<PartialEntry> = KNOWN_ACP_RUNTIMES
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
.map(discover_acp_runtime_phase1)
|
||||
.collect();
|
||||
|
||||
// Phase 2: run auth probes in parallel for entries that need them.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
@@ -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
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-[760px] text-sm leading-6 text-foreground/90">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ const RUNTIME_SORT_PRIORITY: Record<string, number> = {
|
||||
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)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Instructions
|
||||
{runtimeInstallGuideLabel(runtime)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
@@ -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({
|
||||
) : (
|
||||
<Switch
|
||||
aria-label={`${runtime.label} availability`}
|
||||
checked={isOn}
|
||||
checked={isAvailable}
|
||||
className="disabled:cursor-default disabled:opacity-100"
|
||||
data-testid={`doctor-runtime-toggle-${runtime.id}`}
|
||||
disabled={isAvailable || installSuccess || !canInstall}
|
||||
disabled={isAvailable || !canInstall}
|
||||
onCheckedChange={(checked) => {
|
||||
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({
|
||||
<RuntimeActions
|
||||
authMethods={authMethods}
|
||||
connectingMethodId={connectingMethodId}
|
||||
installSuccess={installSuccess}
|
||||
isConnecting={isConnecting}
|
||||
isInstalling={isInstalling}
|
||||
onConnect={onConnect}
|
||||
@@ -305,7 +307,6 @@ function RuntimeRow({
|
||||
}, [resetEpoch]);
|
||||
const isInstalling = installMutation.isPending;
|
||||
const installError = installResult?.error ?? null;
|
||||
const installSuccess = installResult?.success ?? false;
|
||||
|
||||
function handleInstall() {
|
||||
setInstallResult(null);
|
||||
@@ -362,7 +363,6 @@ function RuntimeRow({
|
||||
<RuntimeHeader
|
||||
authMethods={authMethods}
|
||||
connectingMethodId={connectMutation.variables?.methodId ?? null}
|
||||
installSuccess={installSuccess}
|
||||
isConnecting={connectMutation.isPending}
|
||||
isInstalling={isInstalling}
|
||||
onConnect={(method) => {
|
||||
@@ -391,6 +391,25 @@ function RuntimeRow({
|
||||
runtime={runtime}
|
||||
/>
|
||||
|
||||
{runtime.availability !== "available" ? (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground"
|
||||
data-testid={`doctor-runtime-guidance-${runtime.id}`}
|
||||
>
|
||||
<p>{runtime.installHint}</p>
|
||||
{runtime.installInstructionsUrl.trim().length > 0 ? (
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center gap-1 underline-offset-2 hover:text-foreground hover:underline"
|
||||
onClick={() => void openUrl(runtime.installInstructionsUrl)}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{runtimeInstallGuideLabel(runtime)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtime.authStatus.status === "config_invalid" ? (
|
||||
<p
|
||||
className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive"
|
||||
@@ -400,11 +419,6 @@ function RuntimeRow({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{installSuccess && runtime.availability !== "available" ? (
|
||||
<p className="mt-2 rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-1.5 text-sm text-green-700 dark:text-green-400">
|
||||
{runtime.label} installed. Checking for sign-in options...
|
||||
</p>
|
||||
) : null}
|
||||
{installError ? (
|
||||
<p
|
||||
className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive"
|
||||
|
||||
@@ -184,6 +184,8 @@ export type RawAcpRuntimeCatalogEntry = {
|
||||
install_hint: string;
|
||||
install_instructions_url: string;
|
||||
can_auto_install: boolean;
|
||||
/** Optional only for older E2E fixtures; the Rust catalog always supplies it. */
|
||||
requires_external_cli?: boolean;
|
||||
underlying_cli_path: string | null;
|
||||
node_required: boolean;
|
||||
/** Tagged union with snake_case status values — same shape as `AuthStatus`. */
|
||||
@@ -743,6 +745,7 @@ function fromRawAcpRuntimeCatalogEntry(
|
||||
installHint: entry.install_hint,
|
||||
installInstructionsUrl: entry.install_instructions_url,
|
||||
canAutoInstall: entry.can_auto_install,
|
||||
requiresExternalCli: entry.requires_external_cli ?? false,
|
||||
underlyingCliPath: entry.underlying_cli_path,
|
||||
nodeRequired: entry.node_required,
|
||||
authStatus: entry.auth_status,
|
||||
|
||||
@@ -548,6 +548,8 @@ export type AcpRuntimeCatalogEntry = {
|
||||
installHint: string;
|
||||
installInstructionsUrl: string;
|
||||
canAutoInstall: boolean;
|
||||
/** True when the runtime depends on a separately installed vendor CLI. */
|
||||
requiresExternalCli: boolean;
|
||||
underlyingCliPath: string | null;
|
||||
/** True when an npm adapter step is pending but Node.js / npm is absent. */
|
||||
nodeRequired: boolean;
|
||||
|
||||
@@ -6972,6 +6972,7 @@ async function handleDiscoverAcpRuntimes(
|
||||
install_hint: "Install Goose via the official install script.",
|
||||
install_instructions_url: "https://block.github.io/goose/",
|
||||
can_auto_install: true,
|
||||
requires_external_cli: true,
|
||||
underlying_cli_path: null,
|
||||
node_required: false,
|
||||
auth_status: { status: "not_applicable" },
|
||||
@@ -6990,6 +6991,7 @@ async function handleDiscoverAcpRuntimes(
|
||||
install_instructions_url:
|
||||
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
|
||||
can_auto_install: true,
|
||||
requires_external_cli: true,
|
||||
underlying_cli_path: "/usr/local/bin/claude",
|
||||
node_required: false,
|
||||
auth_status: { status: "unknown" },
|
||||
@@ -7008,6 +7010,7 @@ async function handleDiscoverAcpRuntimes(
|
||||
"The codex-acp adapter must be built from source. See the GitHub repo.",
|
||||
install_instructions_url: "https://github.com/openai/codex",
|
||||
can_auto_install: false,
|
||||
requires_external_cli: true,
|
||||
underlying_cli_path: null,
|
||||
node_required: false,
|
||||
auth_status: { status: "unknown" },
|
||||
@@ -7025,6 +7028,7 @@ async function handleDiscoverAcpRuntimes(
|
||||
install_hint: "Ships with the Buzz desktop app.",
|
||||
install_instructions_url: "https://github.com/block/buzz",
|
||||
can_auto_install: false,
|
||||
requires_external_cli: false,
|
||||
underlying_cli_path: null,
|
||||
node_required: false,
|
||||
auth_status: { status: "not_applicable" },
|
||||
|
||||
@@ -22,7 +22,8 @@ const GOOSE_AVAILABLE = {
|
||||
default_args: ["acp"],
|
||||
mcp_command: null,
|
||||
install_hint: "",
|
||||
install_instructions_url: "https://block.github.io/goose/",
|
||||
install_instructions_url:
|
||||
"https://goose-docs.ai/docs/getting-started/installation/",
|
||||
can_auto_install: false,
|
||||
underlying_cli_path: null,
|
||||
node_required: false,
|
||||
@@ -82,8 +83,9 @@ const CODEX_NOT_INSTALLED = {
|
||||
binary_path: null,
|
||||
default_args: [],
|
||||
mcp_command: null,
|
||||
install_hint: "Install the Codex CLI, then install the ACP adapter via npm.",
|
||||
install_instructions_url: "https://github.com/zed-industries/codex-acp",
|
||||
install_hint:
|
||||
"Buzz requires the Codex CLI; the desktop app alone is not enough.",
|
||||
install_instructions_url: "https://developers.openai.com/codex/cli/",
|
||||
can_auto_install: true,
|
||||
underlying_cli_path: null,
|
||||
node_required: false,
|
||||
@@ -160,7 +162,7 @@ test.describe("Doctor panel state screenshots", () => {
|
||||
),
|
||||
),
|
||||
);
|
||||
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" }),
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user