Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,12 @@ fn command_search_dirs() -> Vec<PathBuf> {
.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);
Expand Down Expand Up @@ -433,10 +439,21 @@ fn resolve_workspace_command(command: &str) -> Option<PathBuf> {
}

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<std::collections::HashMap<String, Option<PathBuf>>>
Expand Down Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
66 changes: 59 additions & 7 deletions desktop/src/features/agents/ui/AgentConfigFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -331,10 +356,6 @@ export function AgentConfigFields({
)
? selectedRuntimeId
: "buzz-agent";
const bakedEnvKeys = React.useMemo(
() => bakedEnv.map((entry) => entry.key),
[bakedEnv],
);
const {
advancedCredentialMissing,
advancedFileSatisfiedEnvKeys,
Expand All @@ -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]);
Expand Down Expand Up @@ -770,6 +794,26 @@ export function AgentConfigFields({
</div>
) : null}

{providerFieldVisible && effectiveProvider === "openai-compat" ? (
<div className={blockClassName}>
<PersonaProviderBaseUrlField
disabled={false}
isRequired={isBaseUrlRequired}
label="OpenAI-compatible base URL"
onValueChange={(value) =>
onConfigChange({
...config,
env_vars: {
...config.env_vars,
[OPENAI_COMPAT_BASE_URL_ENV_KEY]: value,
},
})
}
value={baseUrlValue}
/>
</div>
) : null}

{/* Model field — omitted only after confirmed successful empty discovery */}
{modelControlVisible ? (
<div className={showDescriptions ? fieldClassName : undefined}>
Expand Down Expand Up @@ -909,7 +953,11 @@ export function AgentConfigFields({
>
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
hiddenKeys={
apiKeyEnvVar
? [apiKeyEnvVar, OPENAI_COMPAT_BASE_URL_ENV_KEY]
: [OPENAI_COMPAT_BASE_URL_ENV_KEY]
}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Environment variables"
Expand All @@ -927,7 +975,11 @@ export function AgentConfigFields({
) : advancedOpen ? (
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
hiddenKeys={
apiKeyEnvVar
? [apiKeyEnvVar, OPENAI_COMPAT_BASE_URL_ENV_KEY]
: [OPENAI_COMPAT_BASE_URL_ENV_KEY]
}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Environment variables"
Expand Down
90 changes: 90 additions & 0 deletions desktop/src/features/agents/ui/PersonaProviderBaseUrlField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as React from "react";

import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { RequiredFieldLabel } from "./agentConfigControls";
import {
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
} from "./agentConfigOptions";

/** Validate that a non-empty string is parseable as a URL. */
export function isValidUrl(value: string): boolean {
const trimmed = value.trim();
if (trimmed.length === 0) return false;
try {
// eslint-disable-next-line no-new
new URL(trimmed);
return true;
} catch {
return false;
}
}

/**
* Base URL pseudo-field for the OpenAI-compatible provider.
*
* Mirrors PersonaProviderApiKeyField: it is a pure view over an env var
* (OPENAI_COMPAT_BASE_URL) and shares the same styled shell. The value is
* not a secret, so it renders as a plain URL input with format validation.
*/
export function PersonaProviderBaseUrlField({
disabled,
isRequired,
label,
onValueChange,
value,
}: {
disabled: boolean;
/** True when the base URL must be provided for the selected provider. */
isRequired: boolean;
/** Display label, e.g. "OpenAI-compatible base URL". */
label: string;
onValueChange: (next: string) => 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 (
<div className="space-y-1.5">
<RequiredFieldLabel htmlFor={inputId} isRequired={isRequired}>
{label}
</RequiredFieldLabel>
<div
className={cn(
"flex min-h-11 items-center gap-2 px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoComplete="off"
className={cn(
"h-8 flex-1 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
data-testid="persona-provider-base-url"
disabled={disabled}
id={inputId}
onBlur={() => setTouched(true)}
onChange={(event) => onValueChange(event.target.value)}
placeholder="Paste OpenAI-compatible base URL"
type="url"
value={value}
/>
</div>
{showError ? (
<p
className="text-sm text-destructive"
data-testid="persona-provider-base-url-error"
>
Enter a valid URL (e.g. https://api.venice.ai/v1).
</p>
) : null}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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"],
);
});

Expand All @@ -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"),
Expand All @@ -65,6 +65,6 @@ test("ready onboarding runtimes exclude hidden ready harnesses", () => {

assert.deepEqual(
getReadyOnboardingRuntimes(runtimes).map(({ id }) => id),
["claude"],
["claude", "buzz-agent"],
);
});
Original file line number Diff line number Diff line change
@@ -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<string>(
ONBOARDING_RUNTIME_ORDER,
Expand Down