mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): surface BYOH availability truthfully — auth copy, install hints, adapter-missing presets
Three generic fixes for the #2773 hand-test bugs: - Auth-required discovery errors (spec ErrorCode::AuthRequired text, e.g. signed-out cursor-agent) now render sign-in copy naming the agent, threaded from selectedRuntime.label. Matches the message text, never code -32000 (the catch-all fallback). - The agent-definition dialog's not-installed warning now appends a non-empty install_hint (runtimeAvailabilityWarning helper) — presets always carried the right sentence; the requiresExternalCli render gate hid it. - PresetHarness.underlying_cli (Amp: amp-acp wraps amp) feeds preset_catalog_entry, which consumes only the adapter-missing arm of the builtin predicate: CLI-present/adapter-absent now reads AdapterMissing instead of NotInstalled, while adapter-present/ CLI-absent stays Available and selectable (full classify_runtime reuse would have regressed it to CliMissing with backwards copy). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
83545f1b86
commit
bbf9959960
@@ -326,7 +326,14 @@ const overrides = new Map([
|
||||
// stale-publish regression is pinned through the REAL discover_acp_runtimes_from
|
||||
// path (Wren's finding: the seam-only tests stayed green under a stale-publish
|
||||
// mutant). Test-only code, zero release-build footprint.
|
||||
["src-tauri/src/managed_agents/discovery.rs", 1780],
|
||||
// +55: #2773 follow-up — PresetHarness.underlying_cli (Amp's amp-acp wraps
|
||||
// the amp CLI) + preset_catalog_entry helper: adapter presence alone keeps
|
||||
// deciding Available (adapter-present/CLI-absent stays selectable, Wren's
|
||||
// regression catch); underlying_cli is consulted only when the adapter is
|
||||
// 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],
|
||||
// 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) +
|
||||
@@ -366,7 +373,12 @@ const overrides = new Map([
|
||||
// pre_publish_test_hook; verified to red under a stale-publish mutant.
|
||||
// +18: flake fix — lock_path_mutex + registry_test_lock guards (with lock-
|
||||
// order comments) on the four tests that drive discovery's global caches.
|
||||
["src-tauri/src/managed_agents/discovery/tests.rs", 1787],
|
||||
// +84: #2773 follow-up — preset_catalog_entry coverage (Amp-shaped adapter
|
||||
// preset: AdapterMissing when CLI present, NotInstalled both-missing,
|
||||
// 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],
|
||||
// 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
|
||||
|
||||
@@ -1425,6 +1425,91 @@ struct PresetHarness {
|
||||
args: &'static [&'static str],
|
||||
install_instructions_url: &'static str,
|
||||
install_hint: &'static str,
|
||||
/// Vendor CLI the ACP command wraps, when the preset is an adapter
|
||||
/// (e.g. Amp's `amp-acp` wraps the separately-installed `amp` CLI).
|
||||
/// Consulted only when the adapter is absent, so `AdapterMissing`
|
||||
/// replaces the misleading `NotInstalled` when the CLI is present but
|
||||
/// the adapter is not. Deliberately NOT fed through the builtins'
|
||||
/// full `classify_runtime` predicate: that would flip
|
||||
/// adapter-present/CLI-absent from today's `Available` to `CliMissing`
|
||||
/// (unselectable), and presets carry a single flat `install_hint`, so
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Build the catalog entry for one preset harness through an injectable
|
||||
/// resolver — the seam the preset loop consumes and tests bind.
|
||||
///
|
||||
/// Availability consumes only the adapter-missing arm of the builtin
|
||||
/// predicate: adapter presence alone decides `Available` (exactly today's
|
||||
/// behavior — an `amp-acp` without `amp` stays selectable), and
|
||||
/// `underlying_cli` is consulted only when the adapter is absent, to
|
||||
/// 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.
|
||||
fn preset_catalog_entry(
|
||||
def: &PresetHarness,
|
||||
resolve: impl Fn(&str) -> Option<PathBuf>,
|
||||
) -> AcpRuntimeCatalogEntry {
|
||||
let (availability, command, binary_path) = match resolve(def.command) {
|
||||
Some(path) => (
|
||||
AcpAvailabilityStatus::Available,
|
||||
Some(def.command.to_string()),
|
||||
Some(path.display().to_string()),
|
||||
),
|
||||
None => {
|
||||
let underlying_cli_found = def
|
||||
.underlying_cli
|
||||
.map(|cli| resolve(cli).is_some())
|
||||
.unwrap_or(false);
|
||||
if underlying_cli_found {
|
||||
(AcpAvailabilityStatus::AdapterMissing, None, None)
|
||||
} else {
|
||||
(AcpAvailabilityStatus::NotInstalled, None, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
let underlying_cli_path = def
|
||||
.underlying_cli
|
||||
.and_then(resolve)
|
||||
.map(|p| p.display().to_string());
|
||||
|
||||
let default_args = normalize_agent_args(
|
||||
def.command,
|
||||
def.args.iter().map(|s| s.to_string()).collect(),
|
||||
);
|
||||
|
||||
AcpRuntimeCatalogEntry {
|
||||
id: def.id.to_string(),
|
||||
label: def.label.to_string(),
|
||||
// No remote URL — all preset icons are bundled assets.
|
||||
avatar_url: String::new(),
|
||||
availability,
|
||||
command,
|
||||
binary_path,
|
||||
default_args,
|
||||
mcp_command: None,
|
||||
model_env_var: None,
|
||||
provider_env_var: None,
|
||||
thinking_env_var: None,
|
||||
install_hint: def.install_hint.to_string(),
|
||||
install_instructions_url: def.install_instructions_url.to_string(),
|
||||
can_auto_install: false,
|
||||
// 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.
|
||||
// The builtin path, with per-availability hints, is the only
|
||||
// consumer of the true case.
|
||||
requires_external_cli: false,
|
||||
underlying_cli_path,
|
||||
node_required: false,
|
||||
auth_status: AuthStatus::NotApplicable,
|
||||
login_hint: None,
|
||||
source: HarnessSource::Preset,
|
||||
// Preset entries have static, non-editable env; definition_env is empty.
|
||||
definition_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
@@ -1435,6 +1520,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
args: &["acp"],
|
||||
install_instructions_url: "https://cursor.com/downloads",
|
||||
install_hint: "Install Cursor from cursor.com/downloads.",
|
||||
underlying_cli: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "omp",
|
||||
@@ -1443,6 +1529,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
args: &["acp"],
|
||||
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,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "grok",
|
||||
@@ -1451,6 +1538,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
args: &["agent", "--always-approve", "stdio"],
|
||||
install_instructions_url: "https://build.x.ai/docs",
|
||||
install_hint: "Install Grok Build from build.x.ai.",
|
||||
underlying_cli: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "opencode",
|
||||
@@ -1459,6 +1547,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
args: &["acp"],
|
||||
install_instructions_url: "https://opencode.ai/docs",
|
||||
install_hint: "Install OpenCode from opencode.ai/docs.",
|
||||
underlying_cli: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "kimi",
|
||||
@@ -1467,6 +1556,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
args: &["acp"],
|
||||
install_instructions_url: "https://kimi.ai/download",
|
||||
install_hint: "Install Kimi Code from kimi.ai/download.",
|
||||
underlying_cli: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "amp",
|
||||
@@ -1475,6 +1565,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
args: &[],
|
||||
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"),
|
||||
},
|
||||
PresetHarness {
|
||||
id: "hermes",
|
||||
@@ -1483,6 +1574,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
args: &[],
|
||||
install_instructions_url: "https://hermes-agent.nousresearch.com",
|
||||
install_hint: "Install Hermes Agent from hermes-agent.nousresearch.com.",
|
||||
underlying_cli: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "openclaw",
|
||||
@@ -1498,6 +1590,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
Gateway's execution environment. If your tools or agent logic \
|
||||
needs BUZZ_* credentials at execution time, set them on the \
|
||||
Gateway's own environment separately.",
|
||||
underlying_cli: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1626,45 +1719,7 @@ pub fn discover_acp_runtimes_from(
|
||||
}
|
||||
seen_ids.insert(def.id.to_string());
|
||||
|
||||
let (availability, command, binary_path) = match find_command(def.command) {
|
||||
Some(path) => (
|
||||
AcpAvailabilityStatus::Available,
|
||||
Some(def.command.to_string()),
|
||||
Some(path.display().to_string()),
|
||||
),
|
||||
None => (AcpAvailabilityStatus::NotInstalled, None, None),
|
||||
};
|
||||
|
||||
let default_args = normalize_agent_args(
|
||||
def.command,
|
||||
def.args.iter().map(|s| s.to_string()).collect(),
|
||||
);
|
||||
|
||||
entries.push(AcpRuntimeCatalogEntry {
|
||||
id: def.id.to_string(),
|
||||
label: def.label.to_string(),
|
||||
// No remote URL — all preset icons are bundled assets.
|
||||
avatar_url: String::new(),
|
||||
availability,
|
||||
command,
|
||||
binary_path,
|
||||
default_args,
|
||||
mcp_command: None,
|
||||
model_env_var: None,
|
||||
provider_env_var: None,
|
||||
thinking_env_var: None,
|
||||
install_hint: def.install_hint.to_string(),
|
||||
install_instructions_url: def.install_instructions_url.to_string(),
|
||||
can_auto_install: false,
|
||||
requires_external_cli: false,
|
||||
underlying_cli_path: None,
|
||||
node_required: false,
|
||||
auth_status: AuthStatus::NotApplicable,
|
||||
login_hint: None,
|
||||
source: HarnessSource::Preset,
|
||||
// Preset entries have static, non-editable env; definition_env is empty.
|
||||
definition_env: Default::default(),
|
||||
});
|
||||
entries.push(preset_catalog_entry(def, find_command));
|
||||
}
|
||||
|
||||
// Phase 3: load and append custom harness definitions.
|
||||
|
||||
@@ -6,8 +6,8 @@ use super::{
|
||||
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, probe_codex_acp_major_version, record_agent_command,
|
||||
refresh_login_shell_path, try_record_agent_command, BUZZ_AGENT_AVATAR_URL,
|
||||
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,
|
||||
};
|
||||
use crate::managed_agents::AcpAvailabilityStatus;
|
||||
@@ -189,6 +189,90 @@ fn classifies_cli_missing_when_adapter_found_but_cli_absent() {
|
||||
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
|
||||
}
|
||||
|
||||
/// Amp-shaped preset: an ACP adapter (`amp-acp`) wrapping a separately
|
||||
/// installed vendor CLI (`amp`).
|
||||
const ADAPTER_PRESET: PresetHarness = PresetHarness {
|
||||
id: "amp-test",
|
||||
label: "Amp Test",
|
||||
command: "amp-acp",
|
||||
args: &[],
|
||||
install_instructions_url: "https://example.com/install",
|
||||
install_hint: "Install the amp-acp npm adapter.",
|
||||
underlying_cli: Some("amp"),
|
||||
};
|
||||
|
||||
#[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| {
|
||||
(cmd == "amp").then(|| PathBuf::from("/usr/local/bin/amp"))
|
||||
});
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::AdapterMissing);
|
||||
assert!(entry.command.is_none());
|
||||
assert!(entry.binary_path.is_none());
|
||||
assert_eq!(
|
||||
entry.underlying_cli_path.as_deref(),
|
||||
Some("/usr/local/bin/amp")
|
||||
);
|
||||
assert!(!entry.requires_external_cli);
|
||||
assert_eq!(entry.install_hint, "Install the amp-acp npm adapter.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_entry_not_installed_when_both_missing() {
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(entry.underlying_cli_path.is_none());
|
||||
assert!(!entry.requires_external_cli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_entry_available_when_adapter_and_cli_present() {
|
||||
let entry = preset_catalog_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,
|
||||
});
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
|
||||
assert_eq!(entry.command.as_deref(), Some("amp-acp"));
|
||||
assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp"));
|
||||
assert_eq!(
|
||||
entry.underlying_cli_path.as_deref(),
|
||||
Some("/usr/local/bin/amp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_entry_stays_available_when_adapter_present_but_cli_absent() {
|
||||
// Wren's regression guard: today an `amp-acp` install without `amp`
|
||||
// is Available and selectable. Feeding underlying_cli through the
|
||||
// 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| {
|
||||
(cmd == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp"))
|
||||
});
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
|
||||
assert_eq!(entry.command.as_deref(), Some("amp-acp"));
|
||||
assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp"));
|
||||
assert!(entry.underlying_cli_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_entry_without_underlying_cli_stays_simple() {
|
||||
// Most presets: the command IS the vendor CLI. No external-CLI flag,
|
||||
// absent command means plain NotInstalled.
|
||||
let preset = PresetHarness {
|
||||
underlying_cli: None,
|
||||
..ADAPTER_PRESET
|
||||
};
|
||||
let entry = preset_catalog_entry(&preset, |_| None);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(!entry.requires_external_cli);
|
||||
assert!(entry.underlying_cli_path.is_none());
|
||||
}
|
||||
|
||||
fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition {
|
||||
crate::managed_agents::AgentDefinition {
|
||||
id: id.to_string(),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PersonaDropdownField } from "./PersonaDropdownField";
|
||||
import type { EnvVarsValue } from "./EnvVarsEditor";
|
||||
import { PersonaAdvancedFields } from "./PersonaAdvancedFields";
|
||||
import { PersonaModelField } from "./PersonaModelField";
|
||||
import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning";
|
||||
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
|
||||
import {
|
||||
canSubmitPersonaDialog,
|
||||
@@ -620,19 +621,14 @@ export function AgentDefinitionDialog({
|
||||
);
|
||||
const previewLabel = displayName.trim() || "Agent name";
|
||||
const previewAvatarUrl = avatarUrl.trim() || null;
|
||||
const runtimeWarning =
|
||||
selectedRuntime && selectedRuntime.availability !== "available" ? (
|
||||
<p className="text-xs text-warning">
|
||||
{selectedRuntime.availability === "adapter_missing"
|
||||
? `${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.requiresExternalCli
|
||||
? `${selectedRuntime.label} CLI is missing. ${selectedRuntime.installHint}`
|
||||
: `${selectedRuntime.label} is not installed.`}{" "}
|
||||
Visit Settings > Agents to set it up.
|
||||
</p>
|
||||
) : null;
|
||||
const runtimeWarningText = selectedRuntime
|
||||
? runtimeAvailabilityWarning(selectedRuntime)
|
||||
: null;
|
||||
const runtimeWarning = runtimeWarningText ? (
|
||||
<p className="text-xs text-warning">
|
||||
{runtimeWarningText} Visit Settings > Agents to set it up.
|
||||
</p>
|
||||
) : null;
|
||||
const advancedFieldsTransition = shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: ADVANCED_FIELDS_MOTION_TRANSITION;
|
||||
|
||||
@@ -67,3 +67,46 @@ test("model discovery status stays quiet for missing Databricks defaults", () =>
|
||||
|
||||
assert.equal(status, null);
|
||||
});
|
||||
|
||||
test("auth-required errors name the agent and ask for sign-in", () => {
|
||||
// Real shape from run_agent_models_command wrapping buzz-acp stderr when
|
||||
// cursor-agent is signed out (spec ErrorCode::AuthRequired text).
|
||||
const status = formatModelDiscoveryErrorStatus(
|
||||
new Error(
|
||||
"buzz-acp models failed (exit 1): agent communication failed: Agent reported error (code -32000): Authentication required",
|
||||
),
|
||||
"",
|
||||
"Cursor",
|
||||
);
|
||||
|
||||
assert.equal(status?.tone, "warning");
|
||||
assert.match(status?.message ?? "", /Cursor requires sign-in/);
|
||||
assert.match(status?.message ?? "", /Sign in with the Cursor CLI/);
|
||||
});
|
||||
|
||||
test("auth-required copy degrades gracefully without an agent label", () => {
|
||||
const status = formatModelDiscoveryErrorStatus(
|
||||
new Error("Agent reported error (code -32000): Authentication required"),
|
||||
"",
|
||||
);
|
||||
|
||||
assert.equal(status?.tone, "warning");
|
||||
assert.match(status?.message ?? "", /This agent requires sign-in/);
|
||||
});
|
||||
|
||||
test("non-auth -32000 errors do NOT get the sign-in copy", () => {
|
||||
// -32000 is the catch-all fallback code for unclassified agent errors
|
||||
// (agent_error_from_json unwrap_or(-32000)); only the spec-reserved
|
||||
// "Authentication required" text may route to the sign-in message.
|
||||
const status = formatModelDiscoveryErrorStatus(
|
||||
new Error(
|
||||
"buzz-acp models failed (exit 1): Agent reported error (code -32000): model catalog fetch timed out",
|
||||
),
|
||||
"anthropic",
|
||||
"Cursor",
|
||||
);
|
||||
|
||||
assert.equal(status?.tone, "warning");
|
||||
assert.doesNotMatch(status?.message ?? "", /sign-in/i);
|
||||
assert.match(status?.message ?? "", /Using built-in model options/);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ function isEmptySharedComputeError(message: string): boolean {
|
||||
export function formatModelDiscoveryErrorStatus(
|
||||
error: unknown,
|
||||
provider: string,
|
||||
agentLabel?: string,
|
||||
): PersonaModelDiscoveryStatus | null {
|
||||
const message = errorMessage(error);
|
||||
|
||||
@@ -87,6 +88,19 @@ export function formatModelDiscoveryErrorStatus(
|
||||
};
|
||||
}
|
||||
|
||||
// Spec-reserved auth error text (agent-client-protocol ErrorCode::AuthRequired),
|
||||
// surfaced verbatim through buzz-acp's stderr — generic across conformant
|
||||
// harnesses (e.g. cursor-agent when not signed in). Match the message text,
|
||||
// NOT code -32000: that code is also the catch-all fallback for unclassified
|
||||
// errors, so matching it would swallow unrelated failures into "sign in".
|
||||
if (message.toLowerCase().includes("authentication required")) {
|
||||
const label = agentLabel?.trim();
|
||||
return {
|
||||
message: `${label || "This agent"} requires sign-in before models can load. Sign in with the ${label || "agent's"} CLI in a terminal, then try again.`,
|
||||
tone: "warning",
|
||||
};
|
||||
}
|
||||
|
||||
if (message.includes("ANTHROPIC_API_KEY required")) {
|
||||
return {
|
||||
message: "Enter an Anthropic API key to load Anthropic models.",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning.ts";
|
||||
|
||||
function entry(overrides) {
|
||||
return {
|
||||
id: "amp",
|
||||
label: "Amp",
|
||||
avatarUrl: "",
|
||||
availability: "not_installed",
|
||||
command: null,
|
||||
binaryPath: null,
|
||||
defaultArgs: [],
|
||||
mcpCommand: null,
|
||||
modelEnvVar: null,
|
||||
providerEnvVar: null,
|
||||
thinkingEnvVar: null,
|
||||
installHint: "Install the amp-acp npm adapter: npm install -g amp-acp.",
|
||||
installInstructionsUrl: "https://example.com",
|
||||
canAutoInstall: false,
|
||||
requiresExternalCli: false,
|
||||
underlyingCliPath: null,
|
||||
nodeRequired: false,
|
||||
authStatus: "not_applicable",
|
||||
loginHint: null,
|
||||
source: "preset",
|
||||
definitionEnv: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("available runtimes produce no warning", () => {
|
||||
assert.equal(
|
||||
runtimeAvailabilityWarning(entry({ availability: "available" })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("not-installed warning includes the install hint", () => {
|
||||
// Tyler's Amp hand-test: the hint was carried by the entry but the old
|
||||
// render gate (requiresExternalCli, hardcoded false for presets) hid it.
|
||||
const warning = runtimeAvailabilityWarning(entry({}));
|
||||
assert.equal(
|
||||
warning,
|
||||
"Amp is not installed. Install the amp-acp npm adapter: npm install -g amp-acp.",
|
||||
);
|
||||
});
|
||||
|
||||
test("empty install hint leaves no trailing space", () => {
|
||||
// Custom harnesses default installHint to "" — an unguarded template
|
||||
// would render "Amp is not installed. ".
|
||||
const warning = runtimeAvailabilityWarning(entry({ installHint: "" }));
|
||||
assert.equal(warning, "Amp is not installed.");
|
||||
});
|
||||
|
||||
test("adapter-missing warning names the adapter and includes the hint", () => {
|
||||
const warning = runtimeAvailabilityWarning(
|
||||
entry({ availability: "adapter_missing" }),
|
||||
);
|
||||
assert.match(
|
||||
warning ?? "",
|
||||
/CLI is installed but the ACP adapter is missing/,
|
||||
);
|
||||
assert.match(warning ?? "", /npm install -g amp-acp/);
|
||||
});
|
||||
|
||||
test("cli-missing external-CLI warning keeps the hint", () => {
|
||||
const warning = runtimeAvailabilityWarning(
|
||||
entry({
|
||||
availability: "cli_missing",
|
||||
requiresExternalCli: true,
|
||||
installHint: "Install Codex from example.com.",
|
||||
label: "Codex",
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
warning,
|
||||
"Codex CLI is missing. Install Codex from example.com.",
|
||||
);
|
||||
});
|
||||
|
||||
test("adapter-outdated warning stays hint-free reinstall copy", () => {
|
||||
const warning = runtimeAvailabilityWarning(
|
||||
entry({ availability: "adapter_outdated" }),
|
||||
);
|
||||
assert.equal(warning, "Amp ACP adapter is outdated — reinstall to continue.");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
* Availability warning sentence for the agent-definition dialog.
|
||||
* Returns null when the runtime is available (no warning to show).
|
||||
*
|
||||
* Non-empty install hints are appended so a not-installed or
|
||||
* adapter-missing runtime tells the user the actual next step (presets
|
||||
* always carry one; custom harnesses may leave it empty — guard on the
|
||||
* trimmed value to avoid a dangling "X is not installed. ").
|
||||
*/
|
||||
export function runtimeAvailabilityWarning(
|
||||
runtime: AcpRuntimeCatalogEntry,
|
||||
): string | null {
|
||||
if (runtime.availability === "available") {
|
||||
return null;
|
||||
}
|
||||
const hint = runtime.installHint.trim();
|
||||
const withHint = (base: string) => (hint ? `${base} ${hint}` : base);
|
||||
switch (runtime.availability) {
|
||||
case "adapter_missing":
|
||||
return withHint(
|
||||
`${runtime.label} CLI is installed but the ACP adapter is missing.`,
|
||||
);
|
||||
case "adapter_outdated":
|
||||
return `${runtime.label} ACP adapter is outdated — reinstall to continue.`;
|
||||
default:
|
||||
return runtime.requiresExternalCli
|
||||
? withHint(`${runtime.label} CLI is missing.`)
|
||||
: withHint(`${runtime.label} is not installed.`);
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export function usePersonaModelDiscovery({
|
||||
// reference from a React Query refetch (same data, unstable ref) does not
|
||||
// abandon and re-issue an in-flight discovery IPC call.
|
||||
const selectedRuntimeAvailability = selectedRuntime?.availability;
|
||||
const selectedRuntimeLabel = selectedRuntime?.label;
|
||||
const selectedRuntimeDefaultArgs = selectedRuntime?.defaultArgs;
|
||||
const selectedRuntimeDefinitionEnv = selectedRuntime?.definitionEnv;
|
||||
const canDiscoverModelOptions =
|
||||
@@ -252,6 +253,7 @@ export function usePersonaModelDiscovery({
|
||||
formatModelDiscoveryErrorStatus(
|
||||
new Error(`Runtime not available: ${selectedRuntimeAvailability}`),
|
||||
trimmedProvider,
|
||||
selectedRuntimeLabel,
|
||||
),
|
||||
);
|
||||
setModelDiscoveryStatusKey(null);
|
||||
@@ -320,7 +322,11 @@ export function usePersonaModelDiscovery({
|
||||
setModelDiscoveryData(null);
|
||||
setModelDiscoveryDataKey(null);
|
||||
setModelDiscoveryStatus(
|
||||
formatModelDiscoveryErrorStatus(error, trimmedProvider),
|
||||
formatModelDiscoveryErrorStatus(
|
||||
error,
|
||||
trimmedProvider,
|
||||
selectedRuntimeLabel,
|
||||
),
|
||||
);
|
||||
setModelDiscoveryStatusKey(activeModelDiscoveryKey);
|
||||
})
|
||||
@@ -355,6 +361,7 @@ export function usePersonaModelDiscovery({
|
||||
selectedRuntimeAvailability,
|
||||
selectedRuntimeDefaultArgs,
|
||||
selectedRuntimeDefinitionEnv,
|
||||
selectedRuntimeLabel,
|
||||
shouldDebounceModelDiscovery,
|
||||
trimmedProvider,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user