mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): let preset harnesses carry adapter auto-install
Preset (Tier-2) harnesses were docs-link only by construction: `preset_catalog_entry` hardcoded `can_auto_install: false`, and the installer resolved runtimes through the builtins-only `known_acp_runtime_exact`, so `amp` returned "unknown runtime". Install commands are now per-entry data on `PresetHarness`, with `can_auto_install` and `node_required` derived from them — adding a second preset later is one catalog line. `AcpInstallPlan` projects the four things the installer consumes from either tier behind one lookup, so neither static type grows the other's fields. Presets structurally carry no CLI install commands: their `underlying_cli` is a status probe, never an install target. Only Amp is populated (`npm install -g amp-acp`). Tier-3 custom harnesses resolve to no plan at all — their install hint is user-typed text, and a one-click button on it would be arbitrary code execution. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -333,7 +333,13 @@ const overrides = new Map([
|
||||
// absent, so AdapterMissing replaces the misleading NotInstalled. Includes
|
||||
// the deliberate-divergence doc comments; net after the inline preset
|
||||
// entries.push block collapsed into the helper.
|
||||
["src-tauri/src/managed_agents/discovery.rs", 1835],
|
||||
// +101: preset auto-install — PresetHarness.adapter_install_commands (per-entry,
|
||||
// populated for Amp only) drives can_auto_install and node_required, the
|
||||
// node_required predicate is extracted and shared with the builtin path, and
|
||||
// AcpInstallPlan / acp_install_plan project the installer's slice of either
|
||||
// tier behind one lookup so presets resolve without widening KnownAcpRuntime.
|
||||
// Includes the tier-boundary doc comments.
|
||||
["src-tauri/src/managed_agents/discovery.rs", 1936],
|
||||
// BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm /
|
||||
// delete_and_warm (persist-mutex serialization for concurrent-safe registry
|
||||
// refresh, B-6). Also: id/collision/load/registry tests (from the file base) +
|
||||
@@ -378,7 +384,12 @@ const overrides = new Map([
|
||||
// Available both-present AND adapter-present/CLI-absent — the selectability
|
||||
// regression guard), bound to an injectable resolver so the tests stay
|
||||
// PATH-independent.
|
||||
["src-tauri/src/managed_agents/discovery/tests.rs", 1871],
|
||||
// +155: preset auto-install coverage — can_auto_install derivation (both
|
||||
// arms), node_required on managed-node-supported AND unsupported platforms,
|
||||
// the available-adapter and docs-only negatives, acp_install_plan resolution
|
||||
// for preset/builtin/custom-id, and a scope guard asserting Amp is the only
|
||||
// preset carrying install commands.
|
||||
["src-tauri/src/managed_agents/discovery/tests.rs", 2026],
|
||||
// identity-import-keyring: the identity resolution state machine's behavioral
|
||||
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
|
||||
// adoption / read-back-corruption / marker-failure arms, recovery-mode
|
||||
@@ -616,7 +627,11 @@ const overrides = new Map([
|
||||
// return value so the frontend immediately has the updated env.
|
||||
// +1: rebase over main (#2680) — requires_external_cli: false added to
|
||||
// save_custom_harness catalog entry construction (new required field).
|
||||
["src-tauri/src/commands/agent_discovery.rs", 2167],
|
||||
// +52: preset auto-install — install_acp_runtime_blocking resolves through
|
||||
// acp_install_plan (builtins then presets) instead of known_acp_runtime_exact,
|
||||
// plus tests pinning that Amp takes the plain adapter-missing arm and that
|
||||
// Phase 1 no-ops for a preset whose vendor CLI is absent.
|
||||
["src-tauri/src/commands/agent_discovery.rs", 2219],
|
||||
// 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
|
||||
|
||||
@@ -294,7 +294,7 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
|
||||
}
|
||||
let _guard = Guard(runtime_id.to_string());
|
||||
|
||||
let runtime = crate::managed_agents::known_acp_runtime_exact(runtime_id)
|
||||
let plan = crate::managed_agents::acp_install_plan(runtime_id)
|
||||
.ok_or_else(|| format!("unknown runtime: {runtime_id}"))?;
|
||||
|
||||
let mut steps = Vec::new();
|
||||
@@ -303,9 +303,13 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
|
||||
// Today every entry in `cli_install_commands` is a curl-pipe; npm-backed
|
||||
// adapter installs live in Phase 2 below where they are rewritten to a
|
||||
// Buzz-private prefix before execution.
|
||||
if let Some(cli) = runtime.underlying_cli {
|
||||
//
|
||||
// Presets carry no CLI install commands, so an absent vendor CLI is a
|
||||
// no-op here, not a failure: their adapter phase can still run (Amp's
|
||||
// `amp-acp` installs and resolves whether or not `amp` is present).
|
||||
if let Some(cli) = plan.underlying_cli {
|
||||
if crate::managed_agents::resolve_command(cli).is_none() {
|
||||
for cmd in runtime.cli_install_commands_for_os() {
|
||||
for cmd in plan.cli_install_commands {
|
||||
let result = run_install_command_with_retry("cli", cmd);
|
||||
let success = result.success;
|
||||
steps.push(result);
|
||||
@@ -325,15 +329,15 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
|
||||
// For the codex runtime, "found" is not enough — the resolved binary must also
|
||||
// pass the 1.x version gate. An outdated 0.16.x adapter must be overwritten by
|
||||
// the new npm install so the CODEX_CONFIG spawn contract works correctly.
|
||||
let adapter_path = runtime
|
||||
.commands
|
||||
let adapter_path = plan
|
||||
.adapter_commands
|
||||
.iter()
|
||||
.find_map(|cmd| crate::managed_agents::resolve_command(cmd));
|
||||
let adapter_probe_path = crate::managed_agents::readiness::cli_probe::augmented_path();
|
||||
if let Some(cmds) = plan_adapter_install(
|
||||
runtime_id,
|
||||
adapter_path.as_deref(),
|
||||
runtime.adapter_install_commands,
|
||||
plan.adapter_install_commands,
|
||||
adapter_probe_path.as_deref(),
|
||||
) {
|
||||
let use_managed_npm =
|
||||
@@ -1481,6 +1485,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── preset (Tier-2) install resolution ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_amp_preset_takes_plain_adapter_missing_arm() {
|
||||
// `plan_adapter_install`'s only special case is codex. Amp must land on
|
||||
// the adapter-missing arm and get its catalog command verbatim — no
|
||||
// uninstall-first sequence.
|
||||
let plan = crate::managed_agents::acp_install_plan("amp").expect("amp resolves");
|
||||
let planned = plan_adapter_install(
|
||||
"amp",
|
||||
None,
|
||||
plan.adapter_install_commands,
|
||||
Some("/usr/bin:/bin"),
|
||||
);
|
||||
assert_eq!(planned, Some(vec!["npm install -g amp-acp"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amp_preset_with_adapter_present_needs_no_install() {
|
||||
// Non-codex + resolved adapter → nothing to do. Pins that the preset
|
||||
// does not inherit codex's version-probe arm.
|
||||
let bin = std::path::Path::new("/usr/local/bin/amp-acp");
|
||||
assert!(
|
||||
plan_adapter_install("amp", Some(bin), &["npm install -g amp-acp"], None).is_none(),
|
||||
"a present preset adapter must not trigger a reinstall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preset_phase_one_no_ops_when_vendor_cli_absent() {
|
||||
// Amp's underlying_cli is Some("amp") but presets carry no CLI install
|
||||
// commands, so the Phase-1 loop body must never execute — an absent
|
||||
// `amp` is a no-op, not an error and not an install attempt.
|
||||
let plan = crate::managed_agents::acp_install_plan("amp").expect("amp resolves");
|
||||
assert!(plan.underlying_cli.is_some());
|
||||
assert_eq!(
|
||||
plan.cli_install_commands.len(),
|
||||
0,
|
||||
"iterating cli_install_commands for a preset must yield no steps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_runtime_has_no_install_plan() {
|
||||
// The guard that keeps custom (Tier-3) harnesses out of the installer.
|
||||
assert!(crate::managed_agents::acp_install_plan("definitely-not-a-runtime").is_none());
|
||||
}
|
||||
|
||||
// ── should_restart_after_install ─────────────────────────────────────────
|
||||
|
||||
/// Setup-mode agent on matching runtime that is now Ready → restart.
|
||||
|
||||
@@ -992,13 +992,34 @@ pub(crate) fn find_command(command: &str) -> Option<PathBuf> {
|
||||
resolve_command(command)
|
||||
}
|
||||
|
||||
/// Returns true when the runtime has at least one adapter install step that
|
||||
/// is an npm global install. Used to determine whether Node.js is required.
|
||||
fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool {
|
||||
runtime
|
||||
.adapter_install_commands
|
||||
/// True when no npm is reachable for an npm-backed install: Buzz cannot
|
||||
/// provide its private Node runtime on this platform AND the machine has no
|
||||
/// system `npm`/`node`.
|
||||
fn npm_unavailable() -> bool {
|
||||
buzz_managed_node_bin_dir().is_none()
|
||||
&& resolve_command("npm").is_none()
|
||||
&& resolve_command("node").is_none()
|
||||
}
|
||||
|
||||
/// `node_required` means Buzz cannot provide npm for this platform, so an
|
||||
/// npm-backed install cannot possibly succeed and Doctor must send the user
|
||||
/// to nodejs.org instead of offering a toggle.
|
||||
///
|
||||
/// Only adapter commands are considered: CLI install steps are curl-pipe
|
||||
/// installers, never npm. `npm_unavailable` is passed in rather than probed
|
||||
/// so the platform branch is testable on any machine.
|
||||
fn node_required(
|
||||
availability: &AcpAvailabilityStatus,
|
||||
adapter_install_commands: &[&str],
|
||||
npm_unavailable: bool,
|
||||
) -> bool {
|
||||
matches!(
|
||||
availability,
|
||||
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled
|
||||
) && adapter_install_commands
|
||||
.iter()
|
||||
.any(|cmd| is_npm_global_install(cmd))
|
||||
&& npm_unavailable
|
||||
}
|
||||
|
||||
/// Returns `true` when `cmd` is an npm global install/uninstall invocation.
|
||||
@@ -1363,13 +1384,11 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
|
||||
// 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();
|
||||
let node_required = node_required(
|
||||
&availability,
|
||||
runtime.adapter_install_commands,
|
||||
npm_unavailable(),
|
||||
);
|
||||
|
||||
PartialEntry {
|
||||
runtime,
|
||||
@@ -1405,10 +1424,18 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
|
||||
///
|
||||
/// Post-install verification only needs to know whether the requested runtime
|
||||
/// resolves, so it should not pay the cost of authenticating every catalog entry.
|
||||
///
|
||||
/// Covers presets as well as builtins: any runtime the installer can act on
|
||||
/// must also be verifiable, or a successful preset install reports as failed.
|
||||
/// The preset arm builds the full catalog entry and keeps only the
|
||||
/// availability — deliberately, so verification can never disagree with what
|
||||
/// the catalog will show the user a moment later.
|
||||
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)
|
||||
if let Some(runtime) = known_acp_runtime_exact(runtime_id) {
|
||||
return Some(discover_acp_runtime_phase1(runtime).entry.availability);
|
||||
}
|
||||
preset_harness_exact(runtime_id)
|
||||
.map(|def| preset_catalog_entry(def, find_command, npm_unavailable()).availability)
|
||||
}
|
||||
|
||||
// ── Tier-2 preset harnesses ────────────────────────────────────────────────
|
||||
@@ -1436,6 +1463,15 @@ struct PresetHarness {
|
||||
/// the `CliMissing` copy would tell the user to install the adapter
|
||||
/// they already have. `None` when the command IS the vendor CLI.
|
||||
underlying_cli: Option<&'static str>,
|
||||
/// Shell commands that install this preset's ACP adapter, run in order by
|
||||
/// `install_acp_runtime_blocking`. Empty — the default — means no
|
||||
/// auto-install: the Doctor toggle stays disabled and the card is
|
||||
/// docs-link only. npm-global shapes are rewritten to Buzz's private npm
|
||||
/// prefix before execution, exactly as for the builtins.
|
||||
///
|
||||
/// There is deliberately no CLI-install counterpart: a preset's
|
||||
/// `underlying_cli` is a status probe, never an install target.
|
||||
adapter_install_commands: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// Build the catalog entry for one preset harness through an injectable
|
||||
@@ -1448,9 +1484,14 @@ struct PresetHarness {
|
||||
/// distinguish `AdapterMissing` (vendor CLI present) from `NotInstalled`
|
||||
/// (neither found). See the `underlying_cli` field doc for why the full
|
||||
/// `classify_runtime` predicate is deliberately not used here.
|
||||
///
|
||||
/// `npm_unavailable` is injected for the same reason as `resolve`: it is a
|
||||
/// platform probe, and the `node_required` branch it drives has to be
|
||||
/// exercised on both a managed-node-supported and an unsupported platform.
|
||||
fn preset_catalog_entry(
|
||||
def: &PresetHarness,
|
||||
resolve: impl Fn(&str) -> Option<PathBuf>,
|
||||
npm_unavailable: bool,
|
||||
) -> AcpRuntimeCatalogEntry {
|
||||
let (availability, command, binary_path) = match resolve(def.command) {
|
||||
Some(path) => (
|
||||
@@ -1480,6 +1521,8 @@ fn preset_catalog_entry(
|
||||
def.args.iter().map(|s| s.to_string()).collect(),
|
||||
);
|
||||
|
||||
let node_required = node_required(&availability, def.adapter_install_commands, npm_unavailable);
|
||||
|
||||
AcpRuntimeCatalogEntry {
|
||||
id: def.id.to_string(),
|
||||
label: def.label.to_string(),
|
||||
@@ -1495,7 +1538,7 @@ fn preset_catalog_entry(
|
||||
thinking_env_var: None,
|
||||
install_hint: def.install_hint.to_string(),
|
||||
install_instructions_url: def.install_instructions_url.to_string(),
|
||||
can_auto_install: false,
|
||||
can_auto_install: !def.adapter_install_commands.is_empty(),
|
||||
// Kept false even for adapter presets: presets carry one flat
|
||||
// install_hint (the adapter's), so the requiresExternalCli
|
||||
// "CLI is missing" wording would pair the wrong noun with it.
|
||||
@@ -1503,7 +1546,7 @@ fn preset_catalog_entry(
|
||||
// consumer of the true case.
|
||||
requires_external_cli: false,
|
||||
underlying_cli_path,
|
||||
node_required: false,
|
||||
node_required,
|
||||
auth_status: AuthStatus::NotApplicable,
|
||||
login_hint: None,
|
||||
source: HarnessSource::Preset,
|
||||
@@ -1521,6 +1564,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://cursor.com/downloads",
|
||||
install_hint: "Install Cursor from cursor.com/downloads.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
},
|
||||
PresetHarness {
|
||||
id: "omp",
|
||||
@@ -1530,6 +1574,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://github.com/can1357/oh-my-pi",
|
||||
install_hint: "Install Oh My Pi from github.com/can1357/oh-my-pi.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
},
|
||||
PresetHarness {
|
||||
id: "grok",
|
||||
@@ -1539,6 +1584,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://build.x.ai/docs",
|
||||
install_hint: "Install Grok Build from build.x.ai.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
},
|
||||
PresetHarness {
|
||||
id: "opencode",
|
||||
@@ -1548,6 +1594,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://opencode.ai/docs",
|
||||
install_hint: "Install OpenCode from opencode.ai/docs.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
},
|
||||
PresetHarness {
|
||||
id: "kimi",
|
||||
@@ -1557,6 +1604,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://kimi.ai/download",
|
||||
install_hint: "Install Kimi Code from kimi.ai/download.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
},
|
||||
PresetHarness {
|
||||
id: "amp",
|
||||
@@ -1566,6 +1614,11 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://github.com/tao12345666333/amp-acp",
|
||||
install_hint: "Install the amp-acp npm adapter: npm install -g amp-acp.",
|
||||
underlying_cli: Some("amp"),
|
||||
// The only preset with an auto-install today. `amp-acp` is the npm
|
||||
// package's own bin name, so the installed global shim is exactly
|
||||
// the `command` probed above. The vendor `amp` CLI is NOT installed
|
||||
// here — see the field doc.
|
||||
adapter_install_commands: &["npm install -g amp-acp"],
|
||||
},
|
||||
PresetHarness {
|
||||
id: "hermes",
|
||||
@@ -1575,6 +1628,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://hermes-agent.nousresearch.com",
|
||||
install_hint: "Install Hermes Agent from hermes-agent.nousresearch.com.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
},
|
||||
PresetHarness {
|
||||
id: "openclaw",
|
||||
@@ -1591,6 +1645,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
needs BUZZ_* credentials at execution time, set them on the \
|
||||
Gateway's own environment separately.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1616,6 +1671,50 @@ pub(crate) fn preset_harness_definitions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn preset_harness_exact(id: &str) -> Option<&'static PresetHarness> {
|
||||
PRESET_HARNESSES.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// The install-time slice of a runtime, projected from either tier.
|
||||
///
|
||||
/// `install_acp_runtime_blocking` consumes exactly these four things, so one
|
||||
/// lookup can serve builtins and presets without either static type growing
|
||||
/// the other's fields — they differ deliberately (see
|
||||
/// `PresetHarness::underlying_cli`). This is the narrow intersection.
|
||||
pub(crate) struct AcpInstallPlan {
|
||||
/// Vendor CLI to probe before the adapter phase.
|
||||
pub underlying_cli: Option<&'static str>,
|
||||
/// CLI install steps, run only when `underlying_cli` is absent. Always
|
||||
/// empty for presets, whose vendor CLI is installed out of band.
|
||||
pub cli_install_commands: &'static [&'static str],
|
||||
/// Adapter install steps. Empty means no auto-install.
|
||||
pub adapter_install_commands: &'static [&'static str],
|
||||
/// Commands to probe for an already-installed adapter.
|
||||
pub adapter_commands: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// Resolve the install plan for `runtime_id`, checking builtins then presets.
|
||||
///
|
||||
/// `None` for an unknown id — including custom (Tier-3) harnesses, whose
|
||||
/// install hint is user-typed text and never an executable plan.
|
||||
pub(crate) fn acp_install_plan(runtime_id: &str) -> Option<AcpInstallPlan> {
|
||||
if let Some(runtime) = known_acp_runtime_exact(runtime_id) {
|
||||
return Some(AcpInstallPlan {
|
||||
underlying_cli: runtime.underlying_cli,
|
||||
cli_install_commands: runtime.cli_install_commands_for_os(),
|
||||
adapter_install_commands: runtime.adapter_install_commands,
|
||||
adapter_commands: runtime.commands,
|
||||
});
|
||||
}
|
||||
let preset = preset_harness_exact(runtime_id)?;
|
||||
Some(AcpInstallPlan {
|
||||
underlying_cli: preset.underlying_cli,
|
||||
cli_install_commands: &[],
|
||||
adapter_install_commands: preset.adapter_install_commands,
|
||||
adapter_commands: std::slice::from_ref(&preset.command),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the static slice of preset harness IDs.
|
||||
///
|
||||
/// Used by `check_id_collision` in `custom_harnesses` to derive the reserved-ID
|
||||
@@ -1712,6 +1811,8 @@ pub fn discover_acp_runtimes_from(
|
||||
entries.iter().map(|e| e.id.clone()).collect();
|
||||
|
||||
// Phase 2.5: insert static preset entries (PATH-probed, not editable/deletable).
|
||||
// The npm probe is platform state, identical for every preset — resolve once.
|
||||
let npm_unavailable = npm_unavailable();
|
||||
for def in PRESET_HARNESSES {
|
||||
if seen_ids.contains(def.id) {
|
||||
// Builtin or earlier preset shadowed this id — skip silently.
|
||||
@@ -1719,7 +1820,7 @@ pub fn discover_acp_runtimes_from(
|
||||
}
|
||||
seen_ids.insert(def.id.to_string());
|
||||
|
||||
entries.push(preset_catalog_entry(def, find_command));
|
||||
entries.push(preset_catalog_entry(def, find_command, npm_unavailable));
|
||||
}
|
||||
|
||||
// Phase 3: load and append custom harness definitions.
|
||||
|
||||
@@ -2,13 +2,14 @@ use std::path::PathBuf;
|
||||
|
||||
use super::overrides::{divergent_agent_command_override, update_time_agent_command_override};
|
||||
use super::{
|
||||
apply_agent_command_update, classify_runtime, codex_adapter_availability,
|
||||
acp_install_plan, apply_agent_command_update, classify_runtime, codex_adapter_availability,
|
||||
codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command,
|
||||
effective_agent_command, find_nvm_default_bin, find_via_login_shell,
|
||||
is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args,
|
||||
parse_semver_tag, preset_catalog_entry, probe_codex_acp_major_version, record_agent_command,
|
||||
refresh_login_shell_path, try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL,
|
||||
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
|
||||
is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, node_required,
|
||||
normalize_agent_args, parse_semver_tag, preset_catalog_entry, preset_harness_ids,
|
||||
probe_codex_acp_major_version, record_agent_command, refresh_login_shell_path,
|
||||
try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL,
|
||||
CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
|
||||
};
|
||||
use crate::managed_agents::AcpAvailabilityStatus;
|
||||
|
||||
@@ -199,13 +200,37 @@ const ADAPTER_PRESET: PresetHarness = PresetHarness {
|
||||
install_instructions_url: "https://example.com/install",
|
||||
install_hint: "Install the amp-acp npm adapter.",
|
||||
underlying_cli: Some("amp"),
|
||||
adapter_install_commands: &["npm install -g amp-acp"],
|
||||
};
|
||||
|
||||
/// Build a preset entry on a platform where npm IS reachable — the case every
|
||||
/// availability/wording test below cares about. The `node_required` platform
|
||||
/// branch has its own dedicated tests.
|
||||
fn preset_entry(
|
||||
def: &PresetHarness,
|
||||
resolve: impl Fn(&str) -> Option<PathBuf>,
|
||||
) -> crate::managed_agents::AcpRuntimeCatalogEntry {
|
||||
preset_catalog_entry(def, resolve, false)
|
||||
}
|
||||
|
||||
/// Docs-link-only preset: the command IS the vendor CLI and Buzz installs
|
||||
/// nothing. The shape of every preset except Amp.
|
||||
const DOCS_ONLY_PRESET: PresetHarness = PresetHarness {
|
||||
id: "docs-only-test",
|
||||
label: "Docs Only Test",
|
||||
command: "vendor-cli",
|
||||
args: &["acp"],
|
||||
install_instructions_url: "https://example.com/install",
|
||||
install_hint: "Install Vendor CLI from example.com.",
|
||||
underlying_cli: None,
|
||||
adapter_install_commands: &[],
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn preset_entry_adapter_missing_when_underlying_cli_present() {
|
||||
// Vendor CLI resolves, adapter does not — the state Tyler's Amp
|
||||
// hand-test hit. Must NOT degrade to the misleading NotInstalled.
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| {
|
||||
let entry = preset_entry(&ADAPTER_PRESET, |cmd| {
|
||||
(cmd == "amp").then(|| PathBuf::from("/usr/local/bin/amp"))
|
||||
});
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::AdapterMissing);
|
||||
@@ -221,7 +246,7 @@ fn preset_entry_adapter_missing_when_underlying_cli_present() {
|
||||
|
||||
#[test]
|
||||
fn preset_entry_not_installed_when_both_missing() {
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None);
|
||||
let entry = preset_entry(&ADAPTER_PRESET, |_| None);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(entry.underlying_cli_path.is_none());
|
||||
assert!(!entry.requires_external_cli);
|
||||
@@ -229,7 +254,7 @@ fn preset_entry_not_installed_when_both_missing() {
|
||||
|
||||
#[test]
|
||||
fn preset_entry_available_when_adapter_and_cli_present() {
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| match cmd {
|
||||
let entry = preset_entry(&ADAPTER_PRESET, |cmd| match cmd {
|
||||
"amp-acp" => Some(PathBuf::from("/usr/local/bin/amp-acp")),
|
||||
"amp" => Some(PathBuf::from("/usr/local/bin/amp")),
|
||||
_ => None,
|
||||
@@ -250,7 +275,7 @@ fn preset_entry_stays_available_when_adapter_present_but_cli_absent() {
|
||||
// FULL classify_runtime predicate would flip this to CliMissing
|
||||
// (unselectable, with backwards install copy) — the adapter-missing
|
||||
// arm is the only one presets consume.
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| {
|
||||
let entry = preset_entry(&ADAPTER_PRESET, |cmd| {
|
||||
(cmd == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp"))
|
||||
});
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
|
||||
@@ -267,12 +292,142 @@ fn preset_entry_without_underlying_cli_stays_simple() {
|
||||
underlying_cli: None,
|
||||
..ADAPTER_PRESET
|
||||
};
|
||||
let entry = preset_catalog_entry(&preset, |_| None);
|
||||
let entry = preset_entry(&preset, |_| None);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(!entry.requires_external_cli);
|
||||
assert!(entry.underlying_cli_path.is_none());
|
||||
}
|
||||
|
||||
// ── Preset auto-install (Tier-2) ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_preset_with_adapter_install_commands_can_auto_install() {
|
||||
let entry = preset_entry(&ADAPTER_PRESET, |_| None);
|
||||
assert!(
|
||||
entry.can_auto_install,
|
||||
"a preset carrying install commands must offer the Doctor toggle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preset_without_adapter_install_commands_cannot_auto_install() {
|
||||
// The docs-link-only shape: every preset except Amp. Nothing to run, so
|
||||
// the toggle must stay disabled rather than invoking a no-op install.
|
||||
let entry = preset_entry(&DOCS_ONLY_PRESET, |_| None);
|
||||
assert!(!entry.can_auto_install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_installable_preset_node_required_when_npm_unavailable() {
|
||||
// Buzz cannot provide npm on this platform and none is on PATH: Doctor
|
||||
// must send the user to nodejs.org instead of offering a toggle that
|
||||
// cannot possibly succeed.
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None, true);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(entry.node_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_installable_preset_not_node_required_when_npm_available() {
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None, false);
|
||||
assert!(!entry.node_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docs_only_preset_never_node_required() {
|
||||
// No npm-backed install to gate, so the nodejs.org nudge would be noise
|
||||
// even on a platform with no npm at all.
|
||||
let entry = preset_catalog_entry(&DOCS_ONLY_PRESET, |_| None, true);
|
||||
assert!(!entry.node_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_preset_not_node_required_despite_missing_npm() {
|
||||
// The adapter already resolves; nothing will be installed, so the
|
||||
// platform's npm situation is irrelevant.
|
||||
let entry = preset_catalog_entry(
|
||||
&ADAPTER_PRESET,
|
||||
|cmd| (cmd == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp")),
|
||||
true,
|
||||
);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
|
||||
assert!(!entry.node_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amp_preset_carries_npm_adapter_install() {
|
||||
// Pins the one populated entry in the shipped catalog. `amp-acp` is both
|
||||
// the probed command and the npm package's bin name, so the installed
|
||||
// global shim is exactly what discovery looks for.
|
||||
let plan = acp_install_plan("amp").expect("amp must resolve as installable");
|
||||
assert_eq!(plan.adapter_install_commands, ["npm install -g amp-acp"]);
|
||||
assert_eq!(plan.adapter_commands, ["amp-acp"]);
|
||||
assert_eq!(plan.underlying_cli, Some("amp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_other_preset_has_no_install_commands() {
|
||||
// Scope guard: Will's ruling is adapters only, and Amp is the only ACP
|
||||
// adapter on npm today. This reds if a vendor-CLI preset gains an
|
||||
// install command without that ruling being revisited.
|
||||
for id in preset_harness_ids() {
|
||||
if *id == "amp" {
|
||||
continue;
|
||||
}
|
||||
let plan = acp_install_plan(id).expect("every preset id must resolve");
|
||||
assert!(
|
||||
plan.adapter_install_commands.is_empty(),
|
||||
"preset {id} must stay docs-link only"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preset_install_plan_carries_no_cli_install_commands() {
|
||||
// A preset's underlying_cli is a status probe, never an install target:
|
||||
// Phase 1 of the installer must find nothing to run and fall through to
|
||||
// the adapter phase rather than erroring.
|
||||
let plan = acp_install_plan("amp").expect("amp must resolve");
|
||||
assert!(plan.underlying_cli.is_some());
|
||||
assert!(
|
||||
plan.cli_install_commands.is_empty(),
|
||||
"presets never install the vendor CLI"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_install_plan_still_resolves_through_shared_lookup() {
|
||||
// Builtins must keep their CLI install steps and full command list.
|
||||
let plan = acp_install_plan("codex").expect("codex must resolve");
|
||||
assert_eq!(plan.underlying_cli, Some("codex"));
|
||||
assert!(!plan.cli_install_commands.is_empty());
|
||||
assert!(plan
|
||||
.adapter_install_commands
|
||||
.iter()
|
||||
.any(|cmd| cmd.contains("codex-acp")));
|
||||
assert!(plan.adapter_commands.contains(&"codex-acp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_harness_id_has_no_install_plan() {
|
||||
// Tier-3 boundary: custom harnesses carry a user-typed install hint.
|
||||
// Resolving one here would wire a one-click button to a user-supplied
|
||||
// shell string. `None` is what keeps `can_auto_install` false for them.
|
||||
assert!(acp_install_plan("my-custom-harness").is_none());
|
||||
assert!(acp_install_plan("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_required_ignores_non_npm_install_commands() {
|
||||
// CLI installers are curl-pipes; they need no npm, so a missing npm must
|
||||
// not gate them.
|
||||
assert!(!node_required(
|
||||
&AcpAvailabilityStatus::NotInstalled,
|
||||
&["curl -fsSL https://example.com/install.sh | bash"],
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition {
|
||||
crate::managed_agents::AgentDefinition {
|
||||
id: id.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user