diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index ab76965259..2406ab286c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -398,6 +398,12 @@ fn command_search_dirs() -> Vec { .ok() .and_then(|path| path.parent().map(Path::to_path_buf)), ); + + // Tauri-bundled sidecars are staged here by scripts/bundle-sidecars.sh. + // Include the directory so local dev builds can resolve them even though + // they carry the host target-triple suffix (e.g. buzz-agent-aarch64-apple-darwin). + dirs.push(workspace_root_dir().join("desktop/src-tauri/binaries")); + dirs.into_iter().fold(Vec::new(), |mut unique, dir| { if !unique.contains(&dir) { unique.push(dir); @@ -433,10 +439,21 @@ fn resolve_workspace_command(command: &str) -> Option { } let file_name = executable_basename(command); - command_search_dirs() - .into_iter() - .map(|dir| dir.join(&file_name)) - .find(|candidate| is_executable_file(candidate)) + let triple = host_target_triple(); + let mut basenames = vec![file_name]; + if triple != "unknown" { + basenames.push(format!("{command}-{triple}")); + } + + for dir in command_search_dirs() { + for basename in &basenames { + let candidate = dir.join(basename); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + } + None } fn resolve_cache() -> &'static std::sync::Mutex>> @@ -549,6 +566,20 @@ pub(crate) fn availability_drift( } } +/// Host target triple used by Tauri-bundled sidecars (e.g. `buzz-agent-aarch64-apple-darwin`). +fn host_target_triple() -> &'static str { + // ponytail: stdlib env::consts gives OS/ARCH; vendor is apple on macOS and unknown on Linux. + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("linux", "aarch64") => "aarch64-unknown-linux-gnu", + ("windows", "x86_64") => "x86_64-pc-windows-msvc", + ("windows", "aarch64") => "aarch64-pc-windows-msvc", + _ => "unknown", + } +} + /// Return all candidate basenames for `command` on the current platform. /// /// Always includes `executable_basename(command)` (appends `.exe` on Windows). diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2af9ddb98c..e55eaa0df6 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -102,6 +102,14 @@ with a TypeScript lookup table or an id comparison in a component. Edit. In Edit, selecting Custom command keeps its required command field beside the harness picker rather than hiding it in Advanced. +10. **Provider-specific credential fields are a structured view over env vars.** + The `openai-compat` provider surfaces `OPENAI_COMPAT_BASE_URL` as a + dedicated input (`PersonaProviderBaseUrlField`) that writes the same env + var the backend already consumes. Like the API key pseudo-field, it is + hidden from the generic Advanced env editor and preserved across provider + switches so users do not lose typed values when flipping back. Provider + checks in these pseudo-fields (e.g. `effectiveProvider === "openai-compat"`) + are limited to credential presentation, not capability gating. ## The tests that enforce this diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..6f6c363502 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -43,6 +43,7 @@ import { AgentModelField, } from "@/features/agents/ui/agentConfigControls"; import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField"; +import { PersonaProviderBaseUrlField } from "@/features/agents/ui/PersonaProviderBaseUrlField"; import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery"; import { BUZZ_AGENT_THINKING_EFFORT, @@ -63,6 +64,8 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { preferred_runtime: null, }; +const OPENAI_COMPAT_BASE_URL_ENV_KEY = "OPENAI_COMPAT_BASE_URL"; + /** Baked env keys that route to structured controls, not the generic env editor. */ const BAKED_STRUCTURED_KEYS = new Set([ "BUZZ_AGENT_PROVIDER", @@ -293,6 +296,28 @@ export function AgentConfigFields({ // CLI-login harnesses apply this setting through ACP rather than an env var // and provide their own default when no model override is persisted. const modelIsOptional = modelField?.targetApplication.kind === "acpNative"; + const bakedEnvKeys = React.useMemo( + () => bakedEnv.map((entry) => entry.key), + [bakedEnv], + ); + const baseUrlValue = + effectiveProvider === "openai-compat" + ? (config.env_vars[OPENAI_COMPAT_BASE_URL_ENV_KEY] ?? "") + : ""; + const isBaseUrlRequired = + effectiveProvider === "openai-compat" && + !bakedEnvKeys.includes(OPENAI_COMPAT_BASE_URL_ENV_KEY); + const isBaseUrlValid = (() => { + const trimmed = baseUrlValue.trim(); + if (trimmed.length === 0) return !isBaseUrlRequired; + try { + // eslint-disable-next-line no-new + new URL(trimmed); + return true; + } catch { + return false; + } + })(); const modelIsValid = modelIsOptional || (config.model?.trim().length ?? 0) > 0 || @@ -331,10 +356,6 @@ export function AgentConfigFields({ ) ? selectedRuntimeId : "buzz-agent"; - const bakedEnvKeys = React.useMemo( - () => bakedEnv.map((entry) => entry.key), - [bakedEnv], - ); const { advancedCredentialMissing, advancedFileSatisfiedEnvKeys, @@ -352,7 +373,10 @@ export function AgentConfigFields({ runtimeId: credentialRuntimeId, }); const configIsValid = - selectedRuntimeId.length > 0 && modelIsValid && credentialsValid; + selectedRuntimeId.length > 0 && + modelIsValid && + credentialsValid && + isBaseUrlValid; React.useEffect(() => { onValidityChange?.(configIsValid); }, [configIsValid, onValidityChange]); @@ -770,6 +794,26 @@ export function AgentConfigFields({ ) : null} + {providerFieldVisible && effectiveProvider === "openai-compat" ? ( +
+ + onConfigChange({ + ...config, + env_vars: { + ...config.env_vars, + [OPENAI_COMPAT_BASE_URL_ENV_KEY]: value, + }, + }) + } + value={baseUrlValue} + /> +
+ ) : null} + {/* Model field — omitted only after confirmed successful empty discovery */} {modelControlVisible ? (
@@ -909,7 +953,11 @@ export function AgentConfigFields({ > void; + /** Current agent-local value of the base URL env var. */ + value: string; +}) { + const [touched, setTouched] = React.useState(false); + const inputId = "persona-provider-base-url"; + const trimmedValue = value.trim(); + const showError = + touched && trimmedValue.length > 0 && !isValidUrl(trimmedValue); + + return ( +
+ + {label} + +
+ setTouched(true)} + onChange={(event) => onValueChange(event.target.value)} + placeholder="Paste OpenAI-compatible base URL" + type="url" + value={value} + /> +
+ {showError ? ( +

+ Enter a valid URL (e.g. https://api.venice.ai/v1). +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs index 7bb3888b34..6a9c633524 100644 --- a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs @@ -25,6 +25,7 @@ import { shouldRenderModelControl, shouldShowModelStatusMessage, } from "./AgentConfigFields.tsx"; +import { isValidUrl } from "./PersonaProviderBaseUrlField.tsx"; test("canonical behaviors: onboarding's values are the only behavior", () => { assert.deepEqual(CANONICAL_CONFIG_BEHAVIORS, { @@ -218,3 +219,19 @@ test("required-model harnesses always keep the control", () => { "required-model harnesses always keep the control", ); }); + +// ── Base URL validation ─────────────────────────────────────────────────────── +// The OpenAI-compatible provider exposes a structured base URL field that +// writes OPENAI_COMPAT_BASE_URL. The same validation runs on input. + +test("isValidUrl_accepts_https_urls", () => { + assert.equal(isValidUrl("https://api.venice.ai/v1"), true); + assert.equal(isValidUrl("https://api.openai.com/v1"), true); +}); + +test("isValidUrl_rejects_empty_or_malformed_urls", () => { + assert.equal(isValidUrl(""), false); + assert.equal(isValidUrl(" "), false); + assert.equal(isValidUrl("not-a-url"), false); + assert.equal(isValidUrl("api.venice.ai/v1"), false); +}); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index b10aa19154..bb5e23d1a3 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -12,11 +12,11 @@ function runtime(id, availability, status) { return { id, availability, authStatus: { status } }; } -test("only Claude Code and Codex are visible in onboarding", () => { +test("Claude Code, Codex, and Buzz Agent are visible in onboarding", () => { assert.equal(runtimeIsVisibleInOnboarding("claude"), true); assert.equal(runtimeIsVisibleInOnboarding("codex"), true); + assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), true); assert.equal(runtimeIsVisibleInOnboarding("goose"), false); - assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), false); assert.equal(runtimeIsVisibleInOnboarding("custom"), false); }); @@ -30,7 +30,7 @@ test("visible onboarding runtimes use the product order", () => { assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex"], + ["claude", "codex", "buzz-agent"], ); }); @@ -55,7 +55,7 @@ test("readiness requires an available and authenticated runtime", () => { ); }); -test("ready onboarding runtimes exclude hidden ready harnesses", () => { +test("ready onboarding runtimes include visible ready harnesses", () => { const runtimes = [ runtime("goose", "available", "not_applicable"), runtime("codex", "available", "logged_out"), @@ -65,6 +65,6 @@ test("ready onboarding runtimes exclude hidden ready harnesses", () => { assert.deepEqual( getReadyOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude"], + ["claude", "buzz-agent"], ); }); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..66427fac41 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,6 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex", "buzz-agent"]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER,