fix(desktop): fold baked build env into in-process model discovery (#1376)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-30 17:43:28 -04:00
committed by GitHub
parent aa1042b162
commit f061ae9c87
10 changed files with 1743 additions and 109 deletions

View File

@@ -130,10 +130,15 @@ const overrides = new Map([
// catalog module; agent_models.rs retains the thin wrapper (~50 lines).
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
// fallback. Queued to split into dedicated discovery modules.
["src-tauri/src/commands/agent_models.rs", 1066],
// Kept activity-feed design fixture: realistic prompt context and tool-heavy
// chatter for render-class test/reference coverage. Queued to split with the
// rest of this list if it grows further.
// +2: baked build env folded under merged_env in both get_agent_models and
// discover_agent_models so in-process discovery sees baked provider config on
// a GUI-launched DMG (the discovery_env_with_baked_floor fold).
// +3: provider tri-state applied in update_managed_agent handler
// (if let Some(provider_update) = input.provider { record.provider = provider_update; }).
["src-tauri/src/commands/agent_models.rs", 1071],
]);
await runFileSizeCheck({

View File

@@ -11,11 +11,11 @@ use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, current_instance_id, default_agent_workdir,
find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas,
managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command,
save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo,
AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse,
DEFAULT_ACP_COMMAND,
discovery_env_with_baked_floor, find_managed_agent_mut, known_acp_runtime,
load_managed_agents, load_personas, managed_agent_avatar_url, missing_command_message,
normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest,
UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
@@ -90,6 +90,7 @@ pub async fn get_agent_models(
)
}; // store lock released — subprocess runs without holding the lock
let merged_env = discovery_env_with_baked_floor(merged_env);
if let Some(models) = discover_openai_compatible_models(
&state.http_client,
effective_provider.as_deref(),
@@ -222,6 +223,7 @@ pub async fn discover_agent_models(
}
}
let merged_env = crate::managed_agents::merged_user_env(&derived_env, &input.env_vars);
let merged_env = discovery_env_with_baked_floor(merged_env);
if let Some(models) = discover_openai_compatible_models(
&state.http_client,
@@ -825,6 +827,9 @@ pub async fn update_managed_agent(
if let Some(model_update) = input.model {
record.model = model_update;
}
if let Some(provider_update) = input.provider {
record.provider = provider_update;
}
if let Some(prompt_update) = input.system_prompt {
record.system_prompt = prompt_update;
}

View File

@@ -4,34 +4,83 @@
//! binary via `BUZZ_BUILD_AGENT_ENV` (base64-encoded, newline-delimited).
//! OSS builds leave the compile-time var unset — nothing is injected.
use std::collections::BTreeMap;
use base64::Engine as _;
/// Return the baked-in build-time env pairs as a map.
///
/// Internal builds (buzz-releases) bake provider/model defaults and arbitrary
/// `KEY=VALUE` pairs into the binary at compile time. This function returns
/// those pairs as an owned map so callers can fold them into an in-process env
/// at the **lowest** precedence layer — user/persona values layered on top
/// override these baked defaults (last-write-wins, matching the existing
/// subprocess ordering where user env is written last).
///
/// OSS builds leave all `option_env!` vars unset, so this returns an empty
/// map — a safe no-op.
pub(crate) fn baked_build_env() -> BTreeMap<String, String> {
build_env_map(
option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER"),
option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL"),
option_env!("BUZZ_DESKTOP_BUILD_AGENT_ENV"),
)
}
/// Assemble a build-time env map from optional raw bake-in values.
///
/// Separated from `baked_build_env` so the assembly logic can be exercised in
/// unit tests without relying on compile-time `option_env!` values being set.
fn build_env_map(
raw_provider: Option<&str>,
raw_model: Option<&str>,
raw_agent_env: Option<&str>,
) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
if let Some(provider) = raw_provider {
if !provider.is_empty() {
map.insert("BUZZ_AGENT_PROVIDER".to_string(), provider.to_string());
}
}
if let Some(model) = raw_model {
if !model.is_empty() {
map.insert("BUZZ_AGENT_MODEL".to_string(), model.to_string());
}
}
if let Some(raw) = raw_agent_env {
// The value was base64-encoded at build time so the single-line Cargo
// output carries all KEY=VALUE pairs without truncation.
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw.as_bytes()) {
if let Ok(text) = std::str::from_utf8(&decoded) {
for (key, value) in parse_agent_env_lines(text) {
map.insert(key.to_string(), value.to_string());
}
}
}
}
map
}
/// Fold the baked build env under `merged_env` so user/persona values win.
///
/// Returns a new map with baked pairs as the floor and `merged_env` on top.
/// OSS builds return `merged_env` unchanged (empty baked map → no-op).
pub(crate) fn discovery_env_with_baked_floor(
merged_env: std::collections::BTreeMap<String, String>,
) -> std::collections::BTreeMap<String, String> {
let mut env = baked_build_env();
env.extend(merged_env);
env
}
/// Inject baked-in provider/model defaults and generic env pairs onto `cmd`.
///
/// Call this BEFORE writing record/persona metadata env vars so that the
/// record's explicit choices (written after) override the baked defaults.
/// User-supplied `record.env_vars` (written last) always win.
pub(crate) fn build_buzz_agent_provider_defaults(cmd: &mut std::process::Command) {
if let Some(provider) = option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER") {
if !provider.is_empty() {
cmd.env("BUZZ_AGENT_PROVIDER", provider);
}
}
if let Some(model) = option_env!("BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL") {
if !model.is_empty() {
cmd.env("BUZZ_AGENT_MODEL", model);
}
}
if let Some(raw) = option_env!("BUZZ_DESKTOP_BUILD_AGENT_ENV") {
// The value was base64-encoded at build time so the single-line Cargo
// output carries all KEY=VALUE pairs without truncation.
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw.as_bytes()) {
if let Ok(text) = std::str::from_utf8(&decoded) {
for (key, value) in parse_agent_env_lines(text) {
cmd.env(key, value);
}
}
}
for (key, value) in baked_build_env() {
cmd.env(key, value);
}
}
@@ -58,7 +107,10 @@ pub(crate) fn parse_agent_env_lines(raw: &str) -> Vec<(&str, &str)> {
#[cfg(test)]
mod tests {
use super::{build_buzz_agent_provider_defaults, parse_agent_env_lines};
use super::{
baked_build_env, build_buzz_agent_provider_defaults, build_env_map,
discovery_env_with_baked_floor, parse_agent_env_lines,
};
#[test]
fn buzz_agent_provider_defaults_empty_in_oss_build() {
@@ -188,4 +240,120 @@ mod tests {
"baked default must not survive when record provider is written after"
);
}
// ── baked_build_env / build_env_map tests ─────────────────────────────
#[test]
fn baked_build_env_is_empty_in_oss_build() {
// In OSS/test builds none of the BUZZ_DESKTOP_BUILD_* compile-time vars
// are set, so baked_build_env() must return an empty map — no
// accidental injection onto in-process discovery.
assert!(
baked_build_env().is_empty(),
"baked_build_env must be empty in OSS builds (no option_env! vars set)"
);
}
#[test]
fn build_env_map_none_inputs_returns_empty() {
assert!(build_env_map(None, None, None).is_empty());
}
#[test]
fn build_env_map_provider_and_model_are_mapped() {
let map = build_env_map(Some("databricks"), Some("my-model"), None);
assert_eq!(
map.get("BUZZ_AGENT_PROVIDER").map(String::as_str),
Some("databricks")
);
assert_eq!(
map.get("BUZZ_AGENT_MODEL").map(String::as_str),
Some("my-model")
);
}
#[test]
fn build_env_map_empty_provider_is_skipped() {
let map = build_env_map(Some(""), Some("my-model"), None);
assert!(
!map.contains_key("BUZZ_AGENT_PROVIDER"),
"empty provider must be skipped"
);
assert_eq!(
map.get("BUZZ_AGENT_MODEL").map(String::as_str),
Some("my-model")
);
}
#[test]
fn build_env_map_agent_env_blob_is_decoded_and_folded() {
use base64::Engine as _;
let raw = "DATABRICKS_HOST=https://block-lakehouse-production.cloud.databricks.com/\nDATABRICKS_MODEL=goose-claude-opus-4-8";
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
let map = build_env_map(None, None, Some(&blob));
assert_eq!(
map.get("DATABRICKS_HOST").map(String::as_str),
Some("https://block-lakehouse-production.cloud.databricks.com/")
);
assert_eq!(
map.get("DATABRICKS_MODEL").map(String::as_str),
Some("goose-claude-opus-4-8")
);
}
#[test]
fn build_env_map_user_env_overrides_baked_via_btreemap_extend() {
// Validates the precedence fold used in agent_models.rs:
// let mut discovery_env = baked_build_env(); // floor
// discovery_env.extend(merged_env); // user wins
use base64::Engine as _;
let raw = "DATABRICKS_HOST=https://baked.example.com/";
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
let mut discovery_env = build_env_map(None, None, Some(&blob));
// User-supplied env_vars override the baked value.
let mut user_env = std::collections::BTreeMap::new();
user_env.insert(
"DATABRICKS_HOST".to_string(),
"https://user.example.com/".to_string(),
);
discovery_env.extend(user_env);
assert_eq!(
discovery_env.get("DATABRICKS_HOST").map(String::as_str),
Some("https://user.example.com/"),
"user env_vars must override baked DATABRICKS_HOST"
);
}
#[test]
fn discovery_env_with_baked_floor_user_key_wins_over_baked() {
use base64::Engine as _;
let raw = "DATABRICKS_HOST=https://baked.example.com/";
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
// Simulate what an internal build would produce via build_env_map.
let _baked = build_env_map(None, None, Some(&blob));
// In OSS test builds baked_build_env() returns empty, so we exercise
// the helper's merge logic directly via merged_env carrying the key.
let mut merged = std::collections::BTreeMap::new();
merged.insert(
"DATABRICKS_HOST".to_string(),
"https://user.example.com/".to_string(),
);
merged.insert("OTHER_KEY".to_string(), "other".to_string());
let result = discovery_env_with_baked_floor(merged);
assert_eq!(
result.get("DATABRICKS_HOST").map(String::as_str),
Some("https://user.example.com/"),
"merged_env key must survive in discovery_env_with_baked_floor output"
);
assert_eq!(
result.get("OTHER_KEY").map(String::as_str),
Some("other"),
"unrelated merged_env keys must pass through unchanged"
);
}
}

View File

@@ -1,6 +1,6 @@
mod agent_env;
pub(crate) mod agent_events;
pub(crate) use agent_env::build_buzz_agent_provider_defaults;
pub(crate) use agent_env::{build_buzz_agent_provider_defaults, discovery_env_with_baked_floor};
mod backend;
pub(crate) mod config_bridge;
mod discovery;

View File

@@ -89,6 +89,8 @@ mod tests {
provider_binary_path: None,
persona_team_dir: None,
persona_name_in_team: None,
persona_source_version: None,
provider: None,
created_at: "now".into(),
updated_at: "now".into(),
last_started_at: None,

View File

@@ -506,6 +506,9 @@ pub struct UpdateManagedAgentRequest {
pub agent_args: Option<Vec<String>>,
#[serde(default)]
pub mcp_command: Option<String>,
/// Absent = don't touch. null = clear to runtime default. "id" = set.
#[serde(default, deserialize_with = "crate::util::double_option")]
pub provider: Option<Option<String>>,
/// Absent = don't touch. Present = set mode.
#[serde(default)]
pub respond_to: Option<RespondTo>,

View File

@@ -209,6 +209,46 @@ fn validate_respond_to_allowlist_accepts_empty() {
assert!(result.is_empty());
}
#[test]
fn update_request_provider_tristate_absent_means_no_touch() {
// A JSON payload with no "provider" key deserialized with `None` —
// the backend must leave the record's existing provider unchanged.
let request: super::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey": "abcd1234"}"#)
.expect("minimal update request should deserialize");
assert!(
request.provider.is_none(),
"absent provider must deserialize to None (don't touch)"
);
}
#[test]
fn update_request_provider_tristate_null_means_clear() {
// A JSON payload with `"provider": null` deserialized with `Some(None)` —
// the backend must clear the record's provider back to the runtime default.
let request: super::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey": "abcd1234", "provider": null}"#)
.expect("null provider request should deserialize");
assert_eq!(
request.provider,
Some(None),
"explicit null must deserialize to Some(None) (clear)"
);
}
#[test]
fn update_request_provider_tristate_value_means_set() {
// A JSON payload with a provider string deserialized with `Some(Some(…))`.
let request: super::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey": "abcd1234", "provider": "databricks_v2"}"#)
.expect("provider value request should deserialize");
assert_eq!(
request.provider,
Some(Some("databricks_v2".to_string())),
"provider value must deserialize to Some(Some(value)) (set)"
);
}
use super::{CreateManagedAgentRequest, RelayMeshConfig};
/// Wire-shape test: the create request arrives from TS as camelCase

View File

@@ -1,16 +1,16 @@
import * as React from "react";
import {
useAcpRuntimesQuery,
usePersonasQuery,
useUpdateManagedAgentMutation,
} from "@/features/agents/hooks";
import type {
AgentModelsResponse,
AcpRuntimeCatalogEntry,
ManagedAgent,
RespondToMode,
UpdateManagedAgentInput,
} from "@/shared/api/types";
import { getAgentModels } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -20,15 +20,35 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import {
AUTO_MODEL_DROPDOWN_VALUE,
AUTO_PROVIDER_DROPDOWN_VALUE,
CUSTOM_MODEL_DROPDOWN_VALUE,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
formatRuntimeOptionLabel,
getModelSelectValue,
getPersonaProviderOptions,
getProviderApiKeyEnvVar,
hasPersonaModelOption,
NO_RUNTIME_DROPDOWN_VALUE,
runtimeSupportsLlmProviderSelection,
shouldClearKnownModelForSelectionScope,
sortPersonaRuntimes,
type PersonaDropdownOption,
type PersonaModelOption,
} from "./personaDialogPickers";
import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel";
import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus";
import {
CreateAgentBasicsFields,
CreateAgentRuntimeFields,
} from "./CreateAgentDialogSections";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import { CreateAgentRespondToField } from "./RespondToField";
const AUTO_MODEL_VALUE = "__auto_model__";
const CUSTOM_MODEL_VALUE = "__custom_model__";
import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
export function EditAgentDialog({
agent,
@@ -42,6 +62,8 @@ export function EditAgentDialog({
onUpdated?: (agent: ManagedAgent) => void;
}) {
const updateMutation = useUpdateManagedAgentMutation();
const runtimesQuery = useAcpRuntimesQuery({ enabled: open });
const runtimes = runtimesQuery.data ?? [];
const [name, setName] = React.useState(agent.name);
const [relayUrl, setRelayUrl] = React.useState(agent.relayUrl);
@@ -66,10 +88,10 @@ export function EditAgentDialog({
agent.systemPrompt ?? "",
);
const [model, setModel] = React.useState(agent.model ?? "");
const [modelsData, setModelsData] =
React.useState<AgentModelsResponse | null>(null);
const [modelsLoading, setModelsLoading] = React.useState(false);
const [modelsError, setModelsError] = React.useState<string | null>(null);
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [provider, setProvider] = React.useState(agent.provider ?? "");
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
const personasQuery = usePersonasQuery();
const linkedPersona = React.useMemo(
@@ -87,6 +109,15 @@ export function EditAgentDialog({
agent.respondToAllowlist,
);
// Runtime selector: defaults to "custom" until the dialog opens and the
// catalog loads. The open-effect re-derives the correct id from the catalog.
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom");
// Tracks whether the user has made an in-dialog runtime selection. When true,
// the catalog-arrival effect must not overwrite it (the user's choice wins).
// Reset to false each time the dialog opens so a fresh open always re-derives.
const runtimeTouched = React.useRef(false);
// Reset form state only when the dialog opens or when switching to a different
// agent. Omitting the full agent object and its array fields from deps prevents
// the effect from firing on every 5s background poll (arrays are never
@@ -108,46 +139,244 @@ export function EditAgentDialog({
setParallelism(String(agent.parallelism));
setSystemPrompt(agent.systemPrompt ?? "");
setModel(agent.model ?? "");
setIsCustomModelEditing(false);
setProvider(agent.provider ?? "");
setIsCustomProviderEditing(false);
setEnvVars(agent.envVars);
setRespondTo(agent.respondTo);
setRespondToAllowlist(agent.respondToAllowlist);
// Re-derive the runtime id from whatever catalog entries have loaded.
// If the catalog hasn't arrived yet, the catalog-arrival effect below
// will re-derive once it does (guarded by runtimeTouched).
runtimeTouched.current = false;
// Match by command path first (explicit pins store the resolved path).
// Fall back to id-match for agents where agentCommand is the short name
// (e.g. "buzz-agent") while the catalog stores the resolved binary path —
// the same id-fallback used in effectiveRuntimeIdForSubmit.
const matched =
runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
runtimes.find((r) => r.id === agent.agentCommand.trim());
setSelectedRuntimeId(matched ? matched.id : "custom");
updateMutation.reset();
}
}, [open, agent.pubkey]);
// Re-derive the runtime id when the catalog loads, but ONLY while the user
// has not made a manual runtime selection (runtimeTouched === false). This
// handles the async race where the dialog opens before runtimes have loaded:
// the open-effect sees [], falls back to "custom", and this effect corrects
// it once the catalog arrives — without re-firing the full open reset (which
// would wipe other edits).
React.useEffect(() => {
if (!open) {
if (!open || runtimeTouched.current || runtimes.length === 0) {
return;
}
// Same dual-match as the open-effect: command path first, then id fallback
// for agents whose agentCommand is the short name (e.g. "buzz-agent").
const matched =
runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
runtimes.find((r) => r.id === agent.agentCommand.trim());
if (matched) {
setSelectedRuntimeId(matched.id);
}
}, [open, runtimes, agent.agentCommand]);
// Build the sorted runtime catalog for the dropdown.
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimes),
[runtimes],
);
// selectedRuntime: catalog entry for the live-selected runtime id.
// When "custom" or an unknown id, falls back to undefined.
const selectedRuntime = React.useMemo(
() => runtimes.find((r) => r.id === selectedRuntimeId),
[runtimes, selectedRuntimeId],
);
// Runtime dropdown options: catalog entries plus "Custom command" fallback.
// Always include the current id in case it came from an unavailable runtime.
const runtimeDropdownValue = selectedRuntimeId || NO_RUNTIME_DROPDOWN_VALUE;
const runtimeDropdownOptions: PersonaDropdownOption[] = React.useMemo(() => {
const options: PersonaDropdownOption[] = [
...sortedRuntimes.map((candidate) => ({
label: formatRuntimeOptionLabel(candidate),
value: candidate.id,
})),
{ label: "Custom command", value: "custom" },
];
// If the current selection isn't in the list, add it so the dropdown isn't blank.
if (
selectedRuntimeId &&
selectedRuntimeId !== "custom" &&
!options.some((o) => o.value === selectedRuntimeId)
) {
options.push({
label: `${selectedRuntimeId} (current)`,
value: selectedRuntimeId,
});
}
return options;
}, [sortedRuntimes, selectedRuntimeId]);
// Provider field is visible only when the LIVE selected runtime supports
// LLM-provider selection. Keying on the live runtime (not the saved provider)
// prevents a stale saved provider from staying visible after switching to a
// locked runtime (e.g. Claude).
const llmProviderFieldVisible = runtimeSupportsLlmProviderSelection(
selectedRuntime?.id ?? selectedRuntimeId,
);
const providerForDiscovery = llmProviderFieldVisible ? provider : "";
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars,
isCustomProviderEditing,
modelFieldVisible: true,
open,
provider: providerForDiscovery,
selectedRuntime,
});
// When the provider scope changes and the current model is no longer valid
// for the new scope, clear it (mirrors Persona's useEffect for the same).
React.useEffect(() => {
if (
!open ||
isCustomModelEditing ||
!shouldClearKnownModelForSelectionScope({
model,
provider: providerForDiscovery,
runtime: selectedRuntime?.id ?? selectedRuntimeId,
})
) {
return;
}
let cancelled = false;
setModelsData(null);
setModelsError(null);
setModelsLoading(true);
setModel("");
setIsCustomModelEditing(false);
}, [
isCustomModelEditing,
model,
open,
providerForDiscovery,
selectedRuntime,
selectedRuntimeId,
]);
getAgentModels(agent.pubkey)
.then((data) => {
if (!cancelled) {
setModelsData(data);
}
function handleRuntimeDropdownChange(nextValue: string) {
const nextRuntimeId = nextValue;
const previousRuntimeId = selectedRuntimeId;
const nextRuntime = runtimes.find((r) => r.id === nextRuntimeId);
const nextCanChooseProvider = runtimeSupportsLlmProviderSelection(
nextRuntime?.id ?? nextRuntimeId,
);
// Mark that the user has made an explicit runtime choice. The catalog-arrival
// effect will no longer overwrite selectedRuntimeId after this point.
runtimeTouched.current = true;
setSelectedRuntimeId(nextRuntimeId);
// When switching to a catalog-known runtime, update the agent command to
// its resolved command so the command field stays consistent.
if (nextRuntime?.command) {
setAgentCommand(nextRuntime.command);
const newArgs = nextRuntime.defaultArgs.join(",");
setAgentArgs(newArgs);
// Selecting a concrete catalog runtime pins the harness — this is the
// authoritative override. Disabling inheritance ensures the runtime is
// actually persisted and prevents a mismatched provider from being saved
// against an inherited runtime that will actually run something else.
setInheritHarness(false);
} else if (nextRuntimeId === "custom") {
// "Custom" means the user wants to type a command; leave agentCommand as-is.
}
// Clear model when switching away from a runtime with a different model scope.
if (
shouldClearModelForRuntimeChange(previousRuntimeId, nextRuntimeId) ||
shouldClearKnownModelForSelectionScope({
model,
provider,
runtime: nextRuntime?.id ?? nextRuntimeId,
})
.catch((error) => {
if (!cancelled) {
setModelsError(
error instanceof Error ? error.message : String(error),
);
}
})
.finally(() => {
if (!cancelled) {
setModelsLoading(false);
}
) {
setModel("");
setIsCustomModelEditing(false);
}
// When switching to a provider-locked runtime, clear provider state so no
// conflicting provider is persisted on a runtime that doesn't support it.
if (!nextCanChooseProvider) {
const previousProviderApiKeyEnvVar = getProviderApiKeyEnvVar(provider);
if (previousProviderApiKeyEnvVar) {
setEnvVars((current) => {
const next = { ...current };
delete next[previousProviderApiKeyEnvVar];
return next;
});
}
setIsCustomModelEditing(false);
setIsCustomProviderEditing(false);
setProvider("");
}
}
function handleProviderDropdownChange(nextValue: string) {
if (nextValue === CUSTOM_PROVIDER_DROPDOWN_VALUE) {
const previousProviderApiKeyEnvVar = getProviderApiKeyEnvVar(provider);
if (previousProviderApiKeyEnvVar) {
setEnvVars((current) => {
const next = { ...current };
delete next[previousProviderApiKeyEnvVar];
return next;
});
}
setIsCustomProviderEditing(true);
setProvider("");
return;
}
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
// Clear the old provider API key when switching providers.
const previousProviderApiKeyEnvVar = getProviderApiKeyEnvVar(provider);
const nextProviderApiKeyEnvVar = getProviderApiKeyEnvVar(nextProvider);
if (
previousProviderApiKeyEnvVar &&
previousProviderApiKeyEnvVar !== nextProviderApiKeyEnvVar
) {
setEnvVars((current) => {
const next = { ...current };
delete next[previousProviderApiKeyEnvVar];
return next;
});
}
return () => {
cancelled = true;
};
}, [open, agent.pubkey]);
setIsCustomProviderEditing(false);
setProvider(nextProvider);
// Clear the model when switching to a provider that requires a different
// explicit model selection.
if (
!isCustomModelEditing &&
shouldClearKnownModelForSelectionScope({
model,
provider: nextProvider,
runtime: selectedRuntime?.id ?? selectedRuntimeId,
})
) {
setModel("");
setIsCustomModelEditing(false);
}
}
function handleOpenChange(next: boolean) {
onOpenChange(next);
@@ -185,6 +414,7 @@ export function EditAgentDialog({
.map((v) => v.trim())
.filter((v) => v.length > 0);
const normalizedModel = model.trim() || null;
const normalizedProvider = provider.trim() || null;
// Harness pin resolution. The backend treats an empty string as the
// "inherit from persona" sentinel (clears the override) and any concrete
@@ -200,6 +430,42 @@ export function EditAgentDialog({
? agentCommand.trim()
: undefined;
// Derive the effective runtime at submit time — the one that will
// actually run AFTER submit. When pinned (inheritHarness=false), it's
// the live dropdown selection. When inheriting, match agent.agentCommand
// first by command (the normal path), then fall back to id-match for
// runtimes where the adapter is missing (command:null in the catalog).
// The id of a known runtime is stable even when its adapter binary is
// absent, so id-fallback lets us classify capability correctly without
// treating a "known adapter missing" as "completely unknown runtime."
const effectiveRuntimeIdForSubmit = inheritHarness
? (runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim())
?.id ??
// Fallback: id-based match for command:null catalog entries (adapter
// missing but runtime is known and its capability is still static).
runtimes.find((r) => r.id === agent.agentCommand.trim())?.id ??
"")
: (selectedRuntime?.id ?? selectedRuntimeId);
// Classify the effective runtime's provider capability as a tri-state so
// the provider submit branch can distinguish "known-locked" (clear) from
// "unknown" (omit). Clearing must ONLY happen when we KNOW the runtime is
// provider-locked (e.g. Claude). When capability is unknown — because the
// catalog is still loading, the query errored, or the inherited command
// matched nothing — we OMIT the field rather than sending null, so a
// transient discovery/loading state never becomes a destructive write.
type ProviderRuntimeCapability = "capable" | "locked" | "unknown";
const matchedCatalogEntry =
effectiveRuntimeIdForSubmit.length > 0
? runtimes.find((r) => r.id === effectiveRuntimeIdForSubmit)
: undefined;
const providerRuntimeCapability: ProviderRuntimeCapability =
matchedCatalogEntry === undefined
? "unknown"
: runtimeSupportsLlmProviderSelection(matchedCatalogEntry.id)
? "capable"
: "locked";
const input: UpdateManagedAgentInput = {
pubkey: agent.pubkey,
name: name.trim() !== agent.name ? name.trim() : undefined,
@@ -239,6 +505,21 @@ export function EditAgentDialog({
normalizedModel !== (agent.model ?? null)
? normalizedModel
: undefined,
// Tri-state provider persistence keyed on providerRuntimeCapability:
// "capable" → persist: value if changed, omit if unchanged.
// "locked" → clear: send null if provider was set, else omit.
// "unknown" → omit always (never send null for a transient state).
// llmProviderFieldVisible is for UX visibility only; not used here.
provider:
providerRuntimeCapability === "capable"
? normalizedProvider !== (agent.provider ?? null)
? normalizedProvider
: undefined
: providerRuntimeCapability === "locked"
? (agent.provider ?? null) !== null
? null
: undefined
: undefined, // "unknown" → omit always
envVars: envVarsChanged(envVars, agent.envVars) ? envVars : undefined,
respondTo: respondTo !== agent.respondTo ? respondTo : undefined,
// The allowlist is preserved across mode toggles in local UI state
@@ -290,13 +571,25 @@ export function EditAgentDialog({
<EditAgentModelField
disabled={updateMutation.isPending}
discoveredModelOptions={discoveredModelOptions}
isCustomModelEditing={isCustomModelEditing}
model={model}
modelsData={modelsData}
modelsError={modelsError}
modelsLoading={modelsLoading}
modelDiscoveryLoading={modelDiscoveryLoading}
modelDiscoveryStatus={modelDiscoveryStatus}
onIsCustomModelEditingChange={setIsCustomModelEditing}
onModelChange={setModel}
/>
{llmProviderFieldVisible ? (
<EditAgentProviderField
disabled={updateMutation.isPending}
isCustomProviderEditing={isCustomProviderEditing}
onProviderChange={handleProviderDropdownChange}
provider={provider}
selectedRuntime={selectedRuntime}
/>
) : null}
{linkedPersona ? (
<div className="space-y-1.5">
<label
@@ -311,7 +604,7 @@ export function EditAgentDialog({
}
type="checkbox"
/>
Inherit harness from persona
Inherit runtime from persona
</label>
<p className="text-xs text-muted-foreground">
{inheritHarness
@@ -319,12 +612,43 @@ export function EditAgentDialog({
linkedPersona.runtime
? ` (${linkedPersona.runtime})`
: ""
}. Editing the persona and respawning propagates the new harness.`
: "Pins this agent to a specific harness command, overriding the persona's runtime."}
}. Editing the persona and respawning propagates the new runtime.`
: "Pins this agent to a specific runtime command, overriding the persona's runtime."}
</p>
</div>
) : null}
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-runtime">
Agent runtime
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
disabled={updateMutation.isPending}
id="agent-runtime"
onChange={(event) =>
handleRuntimeDropdownChange(event.target.value)
}
value={runtimeDropdownValue}
>
{runtimeDropdownOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedRuntime ? (
<p className="text-xs text-muted-foreground">
Detected at{" "}
<span className="font-medium">
{selectedRuntime.binaryPath ??
selectedRuntime.command ??
selectedRuntime.id}
</span>
</p>
) : null}
</div>
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
@@ -345,7 +669,13 @@ export function EditAgentDialog({
// "custom" surfaces the agent-command input so a user can pin a
// harness; when inheriting we hide it (any non-"custom" id) since
// the command comes from the persona's runtime.
selectedRuntimeId={inheritHarness ? "inherit" : "custom"}
selectedRuntimeId={
inheritHarness
? "inherit"
: selectedRuntimeId === "custom"
? "custom"
: "inherit"
}
systemPrompt={systemPrompt}
turnTimeoutSeconds={turnTimeoutSeconds}
/>
@@ -392,39 +722,54 @@ export function EditAgentDialog({
function EditAgentModelField({
disabled,
discoveredModelOptions,
isCustomModelEditing,
model,
modelsData,
modelsError,
modelsLoading,
modelDiscoveryLoading,
modelDiscoveryStatus,
onIsCustomModelEditingChange,
onModelChange,
}: {
disabled: boolean;
discoveredModelOptions: readonly PersonaModelOption[] | null;
isCustomModelEditing: boolean;
model: string;
modelsData: AgentModelsResponse | null;
modelsError: string | null;
modelsLoading: boolean;
modelDiscoveryLoading: boolean;
modelDiscoveryStatus: PersonaModelDiscoveryStatus | null;
onIsCustomModelEditingChange: (value: boolean) => void;
onModelChange: (value: string) => void;
}) {
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const trimmedModel = model.trim();
const modelOptions = modelsData?.models ?? [];
const selectedKnownModel = modelOptions.some(
(option) => option.id === trimmedModel,
// Mirror Persona: static options serve as the fallback when discovery hasn't
// returned yet. Discovered options are ADDITIVE — we never disable the picker
// or hide the custom input just because discovery returned null.
const staticModelOptions: readonly PersonaModelOption[] = [
{ id: "", label: "Default model" },
];
const effectiveModelOptions = discoveredModelOptions ?? staticModelOptions;
// isModelCustom: true when the current model isn't in any known option set.
// We check discovered options (when available) or runtime-static options so
// a previously-saved custom model stays in custom mode even before discovery.
const isModelCustom = !hasPersonaModelOption(
effectiveModelOptions,
trimmedModel,
);
const hasCustomModel = trimmedModel.length > 0 && !selectedKnownModel;
const showCustomModelInput = isCustomModelEditing || hasCustomModel;
const modelSelectValue = showCustomModelInput
? CUSTOM_MODEL_VALUE
: trimmedModel.length === 0
? AUTO_MODEL_VALUE
: selectedKnownModel
? trimmedModel
: CUSTOM_MODEL_VALUE;
const supportsSwitching = modelsData?.supportsSwitching === true;
const selectDisabled = disabled || modelsLoading || !supportsSwitching;
const defaultModelLabel = modelsData?.agentDefaultModel
? `Default model (${modelsData.agentDefaultModel})`
: "Default model";
const modelSelectValue = getModelSelectValue({
isCustomModelEditing,
isModelCustom,
model,
});
// The select is only disabled for mutation pending — never for missing discovery.
// Default/custom options remain usable regardless of discovery state.
const selectDisabled = disabled || modelDiscoveryLoading;
// Show the custom model input whenever custom mode is active or the current
// model is already custom — not gated on discovery having returned.
const showCustomModelInput = isCustomModelEditing || isModelCustom;
return (
<div className="space-y-1.5">
@@ -437,29 +782,36 @@ function EditAgentModelField({
id="agent-model"
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === AUTO_MODEL_VALUE) {
setIsCustomModelEditing(false);
if (nextValue === AUTO_MODEL_DROPDOWN_VALUE) {
onIsCustomModelEditingChange(false);
onModelChange("");
return;
}
if (nextValue === CUSTOM_MODEL_VALUE) {
setIsCustomModelEditing(true);
if (nextValue === CUSTOM_MODEL_DROPDOWN_VALUE) {
onIsCustomModelEditingChange(true);
return;
}
setIsCustomModelEditing(false);
onIsCustomModelEditingChange(false);
onModelChange(nextValue);
}}
value={modelSelectValue}
>
<option value={AUTO_MODEL_VALUE}>{defaultModelLabel}</option>
{modelOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name ?? option.id}
{effectiveModelOptions.map((option) => (
<option
key={option.id}
value={option.id || AUTO_MODEL_DROPDOWN_VALUE}
>
{option.label}
</option>
))}
<option value={CUSTOM_MODEL_VALUE}>Custom model...</option>
{modelDiscoveryLoading && discoveredModelOptions === null ? (
<option disabled value={MODEL_DISCOVERY_LOADING_VALUE}>
Loading models...
</option>
) : null}
<option value={CUSTOM_MODEL_DROPDOWN_VALUE}>Custom model...</option>
</select>
{showCustomModelInput && supportsSwitching ? (
{showCustomModelInput ? (
<Input
aria-label="Custom model ID"
autoCorrect="off"
@@ -470,13 +822,76 @@ function EditAgentModelField({
/>
) : null}
<p className="text-xs text-muted-foreground">
{modelsLoading
{modelDiscoveryLoading
? "Loading models..."
: modelsError
? `Could not load models: ${modelsError}`
: supportsSwitching
: modelDiscoveryStatus !== null
? modelDiscoveryStatus.message
: discoveredModelOptions !== null
? "Saved changes take effect on the next start."
: "This runtime does not report switchable models."}
: "Select a provider above to see available models."}
</p>
</div>
);
}
function EditAgentProviderField({
disabled,
isCustomProviderEditing,
onProviderChange,
provider,
selectedRuntime,
}: {
disabled: boolean;
isCustomProviderEditing: boolean;
onProviderChange: (value: string) => void;
provider: string;
selectedRuntime: AcpRuntimeCatalogEntry | undefined;
}) {
const trimmedProvider = provider.trim();
const providerOptions = getPersonaProviderOptions(
trimmedProvider,
selectedRuntime?.id ?? "",
);
const providerSelectValue = isCustomProviderEditing
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
return (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-provider">
LLM provider
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs disabled:cursor-not-allowed disabled:opacity-60"
disabled={disabled}
id="agent-provider"
onChange={(event) => onProviderChange(event.target.value)}
value={providerSelectValue}
>
{providerOptions.map((option) => (
<option
key={option.id}
value={option.id || AUTO_PROVIDER_DROPDOWN_VALUE}
>
{option.label}
</option>
))}
<option value={CUSTOM_PROVIDER_DROPDOWN_VALUE}>
Custom provider...
</option>
</select>
{isCustomProviderEditing ? (
<Input
aria-label="Custom provider ID"
autoCorrect="off"
disabled={disabled}
onChange={(event) => onProviderChange(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
) : null}
<p className="text-xs text-muted-foreground">
Changing the provider updates the available model list immediately.
</p>
</div>
);

View File

@@ -0,0 +1,995 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
runtimeSupportsLlmProviderSelection,
getPersonaProviderOptions,
} from "./personaDialogPickers.tsx";
import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel.ts";
// ── LLM provider field visibility ──────────────────────────────────────────
//
// The edit dialog shows the provider picker when the current runtime supports
// LLM provider selection. Changing the provider in that picker re-fires
// usePersonaModelDiscovery (keyed on provider), so the model dropdown updates
// without saving. These tests guard the visibility predicate.
test("editAgent_providerFieldVisible_forBuzzAgent", () => {
assert.equal(
runtimeSupportsLlmProviderSelection("buzz-agent"),
true,
"buzz-agent runtime must expose the provider picker",
);
});
test("editAgent_providerFieldVisible_forGoose", () => {
assert.equal(
runtimeSupportsLlmProviderSelection("goose"),
true,
"goose runtime must expose the provider picker",
);
});
test("editAgent_providerFieldHidden_forClaude", () => {
assert.equal(
runtimeSupportsLlmProviderSelection("claude"),
false,
"claude runtime locks the provider; picker must be hidden",
);
});
test("editAgent_providerFieldHidden_forBlankRuntime", () => {
assert.equal(
runtimeSupportsLlmProviderSelection(""),
false,
"blank runtime (catalog miss) must not show the provider picker",
);
});
// ── Provider dropdown options for EditAgentProviderField ────────────────────
//
// The provider dropdown must always contain the well-known providers
// (databricks, databricks_v2, anthropic, openai, openai-compat) plus a
// default-provider fallback entry so users can clear a saved provider.
test("editAgent_providerOptions_includesDatabricksProviders", () => {
const options = getPersonaProviderOptions("", "buzz-agent");
const ids = options.map((o) => o.id);
assert.ok(ids.includes("databricks"), "databricks must be a provider option");
assert.ok(
ids.includes("databricks_v2"),
"databricks_v2 must be a provider option",
);
});
test("editAgent_providerOptions_includesDefaultEntry", () => {
const options = getPersonaProviderOptions("", "buzz-agent");
// The first entry is the default (empty id) — clearing back to runtime default.
assert.equal(
options[0].id,
"",
"first provider option must be the default (empty id)",
);
});
test("editAgent_providerOptions_includesCurrentIfCustom", () => {
const options = getPersonaProviderOptions("my-custom-llm", "buzz-agent");
const ids = options.map((o) => o.id);
assert.ok(
ids.includes("my-custom-llm"),
"a currently-saved custom provider must appear in the dropdown",
);
});
// ── Finding 1 fix: fallback not disabled when discovery returns null ─────────
//
// When discoveredModelOptions is null, the model picker must NOT be disabled
// and the "Custom model..." option must remain selectable. This guards the
// regression where the select was disabled solely on missing discovery.
//
// We can't render React in pure node tests, but we CAN verify that the logic
// for deciding whether to show options is sound: when discovery is null, we
// fall back to staticModelOptions (length > 0), so we always have options.
test("editAgent_modelFallback_staticOptionsWhenDiscoveryNull", () => {
const staticModelOptions = [{ id: "", label: "Default model" }];
// Simulate: discoveredModelOptions === null → effectiveModelOptions is static fallback
const discoveredModelOptions = null;
const effectiveModelOptions = discoveredModelOptions ?? staticModelOptions;
assert.equal(
effectiveModelOptions.length > 0,
true,
"effectiveModelOptions must be non-empty even when discovery returns null",
);
assert.equal(
effectiveModelOptions[0].id,
"",
"fallback option must be the default (empty id)",
);
});
test("editAgent_modelFallback_selectNotDisabledLogic", () => {
// Verify: the correct disabled condition is (disabled || modelDiscoveryLoading),
// NOT (disabled || modelDiscoveryLoading || !hasDiscoveredOptions).
// We test this by confirming that a null discoveredModelOptions does NOT
// set selectDisabled=true when the mutation is not pending and not loading.
const disabled = false; // mutation not pending
const modelDiscoveryLoading = false;
// Old (buggy) logic would include: || !hasDiscoveredOptions
// New (correct) logic:
const selectDisabled = disabled || modelDiscoveryLoading;
assert.equal(
selectDisabled,
false,
"select must not be disabled when not loading and mutation is idle, regardless of discovery result",
);
});
// ── Finding 2 fix: runtime switch enables provider picker ───────────────────
//
// Switching to buzz-agent runtime (which supports LLM provider selection)
// must make the provider field visible, enabling live discovery.
test("editAgent_runtimeSwitch_toBuzzAgentEnablesProvider", () => {
// Simulate: user switches from "claude" to "buzz-agent"
const previousRuntime = "claude";
const nextRuntime = "buzz-agent";
const previousSupportsProvider =
runtimeSupportsLlmProviderSelection(previousRuntime);
const nextSupportsProvider = runtimeSupportsLlmProviderSelection(nextRuntime);
assert.equal(
previousSupportsProvider,
false,
"claude must NOT support provider selection",
);
assert.equal(
nextSupportsProvider,
true,
"buzz-agent MUST support provider selection",
);
// The provider field visibility transitions false → true on runtime change.
assert.equal(
!previousSupportsProvider && nextSupportsProvider,
true,
"switching from claude to buzz-agent must make provider field visible",
);
});
// ── Finding 3 fix: provider field hidden and cleared for locked runtimes ────
//
// When the live runtime is a provider-locked one (e.g. claude), the provider
// field must NOT be visible even if a stale provider value is saved.
test("editAgent_providerFieldHidden_forLockedRuntimeEvenWithSavedProvider", () => {
// Simulate: agent has a stale saved provider "databricks_v2" but
// the live selected runtime is "claude" (provider-locked).
const liveRuntimeId = "claude";
const savedProvider = "databricks_v2";
// New logic: visibility is keyed on LIVE runtime, not saved provider.
const llmProviderFieldVisible =
runtimeSupportsLlmProviderSelection(liveRuntimeId);
assert.equal(
llmProviderFieldVisible,
false,
"provider field must be hidden when live runtime is provider-locked, even if a provider was previously saved",
);
// Confirm: if we had used the old logic (|| savedProvider), it would be visible.
const oldLogic =
runtimeSupportsLlmProviderSelection(liveRuntimeId) ||
savedProvider.trim().length > 0;
assert.equal(
oldLogic,
true,
"old logic would have incorrectly shown the provider field (this confirms the fix is meaningful)",
);
});
// ── Runtime model-clear on change ─────────────────────────────────────────
//
// When the runtime changes, the model should be cleared if the previous
// runtime had a model that's not valid for the next runtime.
test("editAgent_modelClearedOnRuntimeChange", () => {
const previousRuntime = "buzz-agent";
const nextRuntime = "claude";
assert.equal(
shouldClearModelForRuntimeChange(previousRuntime, nextRuntime),
true,
"model must be cleared when switching runtimes",
);
});
test("editAgent_modelNotClearedWhenRuntimeUnchanged", () => {
const runtime = "buzz-agent";
assert.equal(
shouldClearModelForRuntimeChange(runtime, runtime),
false,
"model must NOT be cleared when the runtime stays the same",
);
});
// ── Finding A fix: late catalog arrival does not wipe a valid saved provider ─
//
// When the dialog opens before the runtime catalog has loaded, selectedRuntimeId
// falls back to "custom" (no match). Once the catalog arrives, a separate effect
// re-derives the correct id — but ONLY if the user has not touched the dropdown.
// This ensures a no-op save never silently clears a valid databricks provider.
test("editAgent_catalogArrival_rederivesRuntimeIdWhenNotTouched", () => {
// Simulate: open effect runs with empty runtimes → selectedRuntimeId = "custom".
// Then catalog arrives with the saved agent's runtime.
const agentCommand = "/usr/local/bin/buzz-agent";
const catalog = [
{ id: "buzz-agent", command: agentCommand, defaultArgs: [] },
{ id: "claude", command: "/usr/local/bin/claude", defaultArgs: [] },
];
const runtimeTouched = false; // user has not selected a runtime
// Simulate the catalog-arrival effect logic.
let selectedRuntimeId = "custom"; // seeded by open effect before catalog loaded
if (!runtimeTouched && catalog.length > 0) {
const matched = catalog.find(
(r) => r.command?.trim() === agentCommand.trim(),
);
if (matched) {
selectedRuntimeId = matched.id;
}
}
assert.equal(
selectedRuntimeId,
"buzz-agent",
"catalog-arrival effect must update selectedRuntimeId from 'custom' to the matched runtime",
);
});
test("editAgent_catalogArrival_doesNotOverwriteUserSelection", () => {
// Simulate: user has already picked a runtime (runtimeTouched = true).
// The catalog-arrival effect must not overwrite the user's choice.
const agentCommand = "/usr/local/bin/buzz-agent";
const catalog = [
{ id: "buzz-agent", command: agentCommand, defaultArgs: [] },
];
const runtimeTouched = true; // user already picked goose
let selectedRuntimeId = "goose"; // user's choice
if (!runtimeTouched && catalog.length > 0) {
const matched = catalog.find(
(r) => r.command?.trim() === agentCommand.trim(),
);
if (matched) {
selectedRuntimeId = matched.id;
}
}
assert.equal(
selectedRuntimeId,
"goose",
"catalog-arrival effect must NOT overwrite user's selection when runtimeTouched is true",
);
});
test("editAgent_noOpSavePreservesProvider_whenCatalogLate", () => {
// Simulate the provider persistence logic when catalog arrived late.
// If the catalog-arrival effect correctly sets selectedRuntimeId = "buzz-agent",
// then llmProviderFieldVisible = true and the provider is preserved on save.
const selectedRuntimeId = "buzz-agent"; // correctly derived after catalog arrival
const savedProvider = "databricks_v2";
const normalizedProvider = savedProvider;
// The visibility logic (mirrors the component).
const llmProviderFieldVisible =
runtimeSupportsLlmProviderSelection(selectedRuntimeId);
// The submit logic for provider tri-state.
let providerUpdate;
if (llmProviderFieldVisible) {
// Only send if changed; here unchanged → undefined (no-op).
providerUpdate =
normalizedProvider !== (savedProvider ?? null)
? normalizedProvider
: undefined;
} else {
// Would send null to clear.
providerUpdate = (savedProvider ?? null) !== null ? null : undefined;
}
assert.equal(
llmProviderFieldVisible,
true,
"provider field must be visible once catalog derives buzz-agent runtime",
);
assert.equal(
providerUpdate,
undefined,
"a no-op save must NOT send null to clear the provider when runtime is correctly derived",
);
});
// ── Finding B fix: inherited agent runtime switch produces consistent pair ───
//
// Selecting a concrete catalog runtime in the Edit dialog pins the harness
// (sets inheritHarness=false). This prevents the bad path where inheritHarness
// remains true while the provider is set for a different runtime.
test("editAgent_runtimeDropdown_pinsHarnessWhenConcreteCatalogRuntimeSelected", () => {
// Simulate handleRuntimeDropdownChange for an inherited-Claude agent.
let inheritHarness = true; // starts inherited
// The fixed handler sets inheritHarness=false when a catalog runtime is picked.
const _nextRuntimeId = "buzz-agent";
const catalogRuntime = {
id: "buzz-agent",
command: "/usr/local/bin/buzz-agent",
defaultArgs: [],
};
if (catalogRuntime.command) {
// Catalog runtime selected: pin the harness.
inheritHarness = false;
}
assert.equal(
inheritHarness,
false,
"selecting a concrete catalog runtime must set inheritHarness=false",
);
});
test("editAgent_inheritedAgentRuntimeSwitch_producesConsistentCommandProviderPair", () => {
// Bad path before fix: inheritHarness stays true, so agentCommandUpdate is
// undefined (agent still inherits Claude), but provider="databricks_v2" persists.
//
// After fix: selecting buzz-agent sets inheritHarness=false, so agentCommandUpdate
// resolves to the buzz-agent command, and provider persists consistently.
// Initial state: inherited Claude agent
const inheritHarness = false; // after fix: pinned by runtime switch
const selectedRuntimeCommand = "/usr/local/bin/buzz-agent";
const agentOriginalCommand = ""; // was inheriting, no command
const agentCommandOverride = null;
// Submit logic for agentCommandUpdate (mirrors the component).
const agentCommandUpdate = inheritHarness
? agentCommandOverride != null
? ""
: undefined
: selectedRuntimeCommand.trim() !== agentOriginalCommand
? selectedRuntimeCommand.trim()
: undefined;
const selectedRuntimeId = "buzz-agent";
const llmProviderFieldVisible =
runtimeSupportsLlmProviderSelection(selectedRuntimeId);
const chosenProvider = "databricks_v2";
const savedProvider = null; // was null (inherited Claude, no provider)
const normalizedProvider = chosenProvider;
let providerUpdate;
if (llmProviderFieldVisible) {
providerUpdate =
normalizedProvider !== (savedProvider ?? null)
? normalizedProvider
: undefined;
} else {
providerUpdate = (savedProvider ?? null) !== null ? null : undefined;
}
assert.equal(
agentCommandUpdate,
"/usr/local/bin/buzz-agent",
"after runtime pin, agentCommandUpdate must be the concrete runtime command",
);
assert.equal(
providerUpdate,
"databricks_v2",
"provider must persist consistently with the pinned runtime",
);
// Confirm both sides of the pair are consistent (concrete command + provider).
assert.ok(
agentCommandUpdate != null && providerUpdate != null,
"command and provider must both be set — a mismatched pair is impossible",
);
});
// ── Finding C / D / E fix: provider persistence tri-state ──────────────────
//
// The UI dropdown state (selectedRuntimeId / llmProviderFieldVisible) is for
// visibility only. Provider PERSISTENCE at submit keys on a tri-state derived
// from the EFFECTIVE runtime:
//
// "capable" → persist: value if changed, omit if unchanged.
// "locked" → clear: send null if provider was set, else omit.
// "unknown" → omit always (never send null for a transient/loading state).
//
// The "unknown" state is the key Finding-E addition: it prevents a transient
// catalog-loading state or a command:null entry from destructively clearing a
// valid provider snapshot.
//
// Helper mirrors the component's effectiveRuntimeIdForSubmit + tri-state logic.
function computeProviderCapability({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
}) {
// Step 1: derive the effective runtime id.
// Inherit path: command match first, then id-based fallback for command:null
// entries (known runtime with missing local adapter).
const effectiveRuntimeIdForSubmit = inheritHarness
? (runtimes.find((r) => r.command?.trim() === agentCommand.trim())?.id ??
runtimes.find((r) => r.id === agentCommand.trim())?.id ??
"")
: (selectedRuntime?.id ?? selectedRuntimeId);
// Step 2: look up the catalog entry by id (not command) and classify.
const matchedCatalogEntry =
effectiveRuntimeIdForSubmit.length > 0
? runtimes.find((r) => r.id === effectiveRuntimeIdForSubmit)
: undefined;
if (matchedCatalogEntry === undefined) return "unknown";
return runtimeSupportsLlmProviderSelection(matchedCatalogEntry.id)
? "capable"
: "locked";
}
// Legacy boolean wrapper used by pre-Finding-E tests.
// Maps "capable" → true, "locked" → false, "unknown" → false.
// Note: "unknown" maps to false here (pre-Finding-E behaviour), but the real
// component now treats it as "omit" not "clear", which is what the new tests
// verify separately.
function computeCanPersistAtSubmit(args) {
return computeProviderCapability(args) === "capable";
}
// Helper to simulate the full provider submit branch for the tri-state.
function computeProviderUpdate({ capability, savedProvider, currentProvider }) {
const normalizedProvider = currentProvider?.trim() || null;
if (capability === "capable") {
return normalizedProvider !== (savedProvider ?? null)
? normalizedProvider
: undefined;
}
if (capability === "locked") {
return (savedProvider ?? null) !== null ? null : undefined;
}
// "unknown" → omit always
return undefined;
}
test("editAgent_inheritCheckboxRoundTrip_doesNotPersistProviderOnInheritedRuntime", () => {
// Simulate: inherited Claude agent (agentCommandOverride == null)
// → user picks buzz-agent (inheritHarness→false, selectedRuntimeId='buzz-agent')
// → user picks databricks_v2
// → user RE-CHECKS inherit (inheritHarness→true, selectedRuntimeId STAYS 'buzz-agent')
// → save: effective runtime is inherited (Claude), provider must NOT be persisted.
const inheritHarness = true; // re-checked before save
const agentCommand = "/usr/local/bin/claude"; // original Claude command
const runtimes = [
{ id: "claude", command: "/usr/local/bin/claude", defaultArgs: [] },
{ id: "buzz-agent", command: "/usr/local/bin/buzz-agent", defaultArgs: [] },
];
const selectedRuntimeId = "buzz-agent"; // dropdown state (stale after re-check)
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
const savedProvider = null; // was null on open (inherited Claude had no provider)
const chosenProvider = "databricks_v2"; // chosen while dropdown was buzz-agent
// llmProviderFieldVisible is driven by the live dropdown (buzz-agent → true).
// This is the UX visibility — unchanged by the fix.
const llmProviderFieldVisible =
runtimeSupportsLlmProviderSelection(selectedRuntimeId);
assert.equal(
llmProviderFieldVisible,
true,
"provider field is visible (dropdown shows buzz-agent) — this is the UX state",
);
// llmProviderCanPersistAtSubmit keys on the EFFECTIVE runtime.
// Inherited Claude command → effective runtime = "claude" → not-provider-capable.
const llmProviderCanPersistAtSubmit = computeCanPersistAtSubmit({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
assert.equal(
llmProviderCanPersistAtSubmit,
false,
"provider must NOT be persistable when the inherited effective runtime is Claude",
);
// Submit logic for provider tri-state (mirrors the component).
const normalizedProvider = chosenProvider;
let providerUpdate;
if (llmProviderCanPersistAtSubmit) {
providerUpdate =
normalizedProvider !== (savedProvider ?? null)
? normalizedProvider
: undefined;
} else {
// Clear any stale saved provider, omit if already null.
providerUpdate = (savedProvider ?? null) !== null ? null : undefined;
}
assert.equal(
providerUpdate,
undefined,
"provider update must be omitted (not sent as databricks_v2) when reverting to inherited Claude",
);
});
test("editAgent_inheritCheckboxRoundTrip_clearsStaleSavedProviderWhenRevertingToInherit", () => {
// Variant: agent previously had a provider saved (e.g. was pinned to buzz-agent
// with databricks_v2). User opens edit, re-checks inherit (inherited runtime is
// Claude) → provider must be cleared (sent as null).
const inheritHarness = true; // re-checked before save
const agentCommand = "/usr/local/bin/claude"; // inherited Claude command
const runtimes = [
{ id: "claude", command: "/usr/local/bin/claude", defaultArgs: [] },
{ id: "buzz-agent", command: "/usr/local/bin/buzz-agent", defaultArgs: [] },
];
const selectedRuntimeId = "buzz-agent"; // dropdown state
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
const savedProvider = "databricks_v2"; // was saved on open (pre-existing provider)
const chosenProvider = "databricks_v2"; // unchanged from saved
const llmProviderCanPersistAtSubmit = computeCanPersistAtSubmit({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
const normalizedProvider = chosenProvider;
let providerUpdate;
if (llmProviderCanPersistAtSubmit) {
providerUpdate =
normalizedProvider !== (savedProvider ?? null)
? normalizedProvider
: undefined;
} else {
providerUpdate = (savedProvider ?? null) !== null ? null : undefined;
}
assert.equal(
providerUpdate,
null,
"reverting to inherited Claude when a provider was previously saved must clear it (send null)",
);
});
// ── Finding D fix: inherited provider-capable agent does not lose its provider
// on a name-only / no-op save ────────────────────────────────
//
// An agent with agentCommandOverride==null (inheritHarness=true) but whose
// persona's runtime is buzz-agent/Goose legitimately carries a provider
// snapshot (ManagedAgentRecord.provider). A no-op or name-only save must
// preserve that snapshot — not clear it. The fix derives the effective runtime
// from agent.agentCommand in the catalog rather than using !inheritHarness as
// a blanket not-provider-capable proxy.
test("editAgent_inheritedBuzzAgentProvider_preservedOnNameOnlySave", () => {
// Inherited buzz-agent persona with databricks_v2 snapshot.
// User makes a name-only edit (never touches runtime or provider).
// The catalog-arrival effect correctly derived selectedRuntimeId="buzz-agent".
const inheritHarness = true; // agentCommandOverride == null → inheriting
const agentCommand = "/usr/local/bin/buzz-agent"; // inherited buzz-agent command
const runtimes = [
{ id: "buzz-agent", command: "/usr/local/bin/buzz-agent", defaultArgs: [] },
{ id: "claude", command: "/usr/local/bin/claude", defaultArgs: [] },
];
const selectedRuntimeId = "buzz-agent"; // correctly derived by catalog-arrival effect
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
const savedProvider = "databricks_v2"; // valid provider snapshot
const currentProvider = "databricks_v2"; // unchanged by user
// llmProviderFieldVisible (UX) is true since dropdown shows buzz-agent.
const llmProviderFieldVisible =
runtimeSupportsLlmProviderSelection(selectedRuntimeId);
assert.equal(
llmProviderFieldVisible,
true,
"provider field must be visible for inherited buzz-agent",
);
// The effective runtime for submit: inherited → match agentCommand in catalog → buzz-agent.
const llmProviderCanPersistAtSubmit = computeCanPersistAtSubmit({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
assert.equal(
llmProviderCanPersistAtSubmit,
true,
"inherited buzz-agent runtime IS provider-capable — provider must be persistable",
);
// Submit logic: provider unchanged → omit (no-op).
const normalizedProvider = currentProvider;
let providerUpdate;
if (llmProviderCanPersistAtSubmit) {
providerUpdate =
normalizedProvider !== (savedProvider ?? null)
? normalizedProvider
: undefined;
} else {
providerUpdate = (savedProvider ?? null) !== null ? null : undefined;
}
assert.equal(
providerUpdate,
undefined,
"name-only save on inherited buzz-agent must omit provider (not send null to clear it)",
);
});
test("editAgent_inheritedBuzzAgentProvider_clearsWhenUserSwitchesToInheritedClaude", () => {
// An agent inheriting buzz-agent with databricks_v2, but the persona was
// changed to Claude (agentCommand now resolves to Claude). On save, the
// provider must be cleared (not preserved for a non-capable runtime).
const inheritHarness = true; // still inheriting
const agentCommand = "/usr/local/bin/claude"; // persona now runs Claude
const runtimes = [
{ id: "buzz-agent", command: "/usr/local/bin/buzz-agent", defaultArgs: [] },
{ id: "claude", command: "/usr/local/bin/claude", defaultArgs: [] },
];
const selectedRuntimeId = "claude"; // catalog-arrival effect derives Claude
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
const savedProvider = "databricks_v2"; // stale provider from before persona change
const llmProviderCanPersistAtSubmit = computeCanPersistAtSubmit({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
assert.equal(
llmProviderCanPersistAtSubmit,
false,
"inherited Claude runtime is not provider-capable — stale provider must not be persisted",
);
let providerUpdate;
if (llmProviderCanPersistAtSubmit) {
providerUpdate =
savedProvider !== (savedProvider ?? null) ? savedProvider : undefined;
} else {
providerUpdate = (savedProvider ?? null) !== null ? null : undefined;
}
assert.equal(
providerUpdate,
null,
"stale databricks_v2 on an inherited-Claude agent must be cleared on save",
);
});
// ── Finding E fix: empty catalog and command:null do not clear a valid provider
//
// Two reachable forms can leave effectiveRuntimeIdForSubmit unresolvable:
//
// Form 1: catalog is still loading at submit (runtimes=[]).
// Form 2: catalog is loaded but the entry has command:null (adapter missing).
//
// In both cases, capability is "unknown" — the component must OMIT the provider
// field, never send null. "unknown" is not "locked"; only "locked" clears.
test("editAgent_findingE_emptyCatalog_providerOmittedNotCleared", () => {
// Form 1: runtimes has not loaded yet at submit time.
// Inherited buzz-agent agent with saved databricks_v2.
// A name-only save must omit provider (not send null).
const inheritHarness = true;
const agentCommand = "buzz-agent"; // inherited command (short form)
const runtimes = []; // catalog still loading
const selectedRuntimeId = "custom"; // not yet derived
const selectedRuntime = undefined;
const savedProvider = "databricks_v2";
const currentProvider = "databricks_v2"; // unchanged by user
const capability = computeProviderCapability({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
assert.equal(
capability,
"unknown",
"empty catalog must yield 'unknown' capability — not 'locked'",
);
const providerUpdate = computeProviderUpdate({
capability,
savedProvider,
currentProvider,
});
assert.equal(
providerUpdate,
undefined,
"empty-catalog submit must OMIT provider (undefined), not send null to clear it",
);
});
test("editAgent_findingE_commandNullCatalogEntry_providerPreservedByIdMatch", () => {
// Form 2: catalog loaded, but buzz-agent's entry has command:null (adapter
// binary not installed). The command-based match fails; id-based fallback
// finds the entry. Capability resolves to "capable" via id.
// A name-only save must omit provider (unchanged → no-op, not null-clear).
const inheritHarness = true;
const agentCommand = "buzz-agent"; // inherited command (short form = runtime id)
const runtimes = [
{ id: "buzz-agent", command: null, defaultArgs: [] }, // adapter missing
{ id: "claude", command: "claude-agent-acp", defaultArgs: [] },
];
const selectedRuntimeId = "custom"; // command match failed → not re-derived
const selectedRuntime = undefined;
const savedProvider = "databricks_v2";
const currentProvider = "databricks_v2"; // unchanged
const capability = computeProviderCapability({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
assert.equal(
capability,
"capable",
"command:null buzz-agent entry must resolve to 'capable' via id-based fallback",
);
const providerUpdate = computeProviderUpdate({
capability,
savedProvider,
currentProvider,
});
assert.equal(
providerUpdate,
undefined,
"name-only save on inherited buzz-agent (command:null) must omit provider, not clear it",
);
});
test("editAgent_findingE_lockedRuntimeStillClears", () => {
// Confirm that "locked" (known provider-incapable runtime) still sends null
// to clear a stale provider — the Finding C/D behaviour must not regress.
const inheritHarness = true;
const agentCommand = "claude-agent-acp"; // inherited Claude
const runtimes = [
{ id: "buzz-agent", command: "buzz-agent", defaultArgs: [] },
{ id: "claude", command: "claude-agent-acp", defaultArgs: [] },
];
const selectedRuntimeId = "buzz-agent"; // stale dropdown state (irrelevant)
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
const savedProvider = "databricks_v2"; // stale saved provider
const currentProvider = "databricks_v2";
const capability = computeProviderCapability({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
assert.equal(
capability,
"locked",
"inherited Claude must classify as 'locked'",
);
const providerUpdate = computeProviderUpdate({
capability,
savedProvider,
currentProvider,
});
assert.equal(
providerUpdate,
null,
"locked runtime with a stale saved provider must send null to clear it",
);
});
test("editAgent_findingE_capableBuzzAgentLoadedCatalog_preservedOnNoOpSave", () => {
// Confirm loaded-catalog inherited buzz-agent still preserves provider.
// This is Finding D's good path — must not regress with the tri-state change.
const inheritHarness = true;
const agentCommand = "buzz-agent";
const runtimes = [
{ id: "buzz-agent", command: "buzz-agent", defaultArgs: [] },
{ id: "claude", command: "claude-agent-acp", defaultArgs: [] },
];
const selectedRuntimeId = "buzz-agent";
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
const savedProvider = "databricks_v2";
const currentProvider = "databricks_v2"; // unchanged
const capability = computeProviderCapability({
inheritHarness,
agentCommand,
runtimes,
selectedRuntimeId,
selectedRuntime,
});
assert.equal(capability, "capable", "loaded buzz-agent must be 'capable'");
const providerUpdate = computeProviderUpdate({
capability,
savedProvider,
currentProvider,
});
assert.equal(
providerUpdate,
undefined,
"no-op save on inherited buzz-agent (loaded catalog) must omit provider",
);
});
// ── Bug A fix: inherited-runtime (short-name agentCommand) seeds selectedRuntimeId
// correctly via id-fallback when catalog command is the resolved path
//
// Problem: buzz-agent stores agentCommand="buzz-agent" (short name) while the
// catalog entry has command="/Applications/Buzz.app/.../buzz-agent" (resolved path).
// Command-based matching fails (short name ≠ full path), so selectedRuntimeId
// stayed "custom" → selectedRuntime=undefined → canDiscoverModelOptions=false →
// discovery never fired for inherited agents.
//
// Fix: both seeding spots (open-effect and catalog-arrival effect) now fall back
// to id-based matching when command-path matching misses — same id-fallback used
// by effectiveRuntimeIdForSubmit.
// Helper mirrors the fixed seeding logic from EditAgentDialog.tsx open-effect /
// catalog-arrival effect.
function deriveSelectedRuntimeId(agentCommand, runtimes) {
const matched =
runtimes.find((r) => r.command?.trim() === agentCommand.trim()) ??
runtimes.find((r) => r.id === agentCommand.trim());
return matched ? matched.id : "custom";
}
test("editAgent_bugA_inheritedShortName_resolvesViaIdFallback", () => {
// The core regression: agentCommand is the short name "buzz-agent" but the
// catalog's command is the resolved binary path. Command match fails; id
// fallback must rescue it.
const agentCommand = "buzz-agent"; // short name stored by effective_agent_command
const runtimes = [
{
id: "buzz-agent",
command: "/Applications/Buzz.app/Contents/MacOS/buzz-agent", // resolved path
availability: "available",
defaultArgs: [],
},
{
id: "claude",
command: "/usr/local/bin/claude-agent-acp",
availability: "available",
defaultArgs: [],
},
];
const selectedRuntimeId = deriveSelectedRuntimeId(agentCommand, runtimes);
assert.equal(
selectedRuntimeId,
"buzz-agent",
"short-name agentCommand must resolve to buzz-agent id via id-fallback when command path differs",
);
});
test("editAgent_bugA_inheritedShortName_commandMatchStillWinsWhenPresent", () => {
// Command-path match must still win when it succeeds (no regression for the
// explicit-pin path where agentCommand IS the full resolved path).
const agentCommand = "/usr/local/bin/buzz-agent"; // full path (explicit pin)
const runtimes = [
{
id: "buzz-agent",
command: "/usr/local/bin/buzz-agent",
availability: "available",
defaultArgs: [],
},
];
const selectedRuntimeId = deriveSelectedRuntimeId(agentCommand, runtimes);
assert.equal(
selectedRuntimeId,
"buzz-agent",
"command-path match must still win when agentCommand equals catalog command",
);
});
test("editAgent_bugA_inheritedShortName_discoveryGatePasses", () => {
// Once selectedRuntimeId is correctly resolved to "buzz-agent" via id-fallback,
// selectedRuntime resolves to the available catalog entry, and the discovery gate
// (canDiscoverModelOptions) passes so the model list populates.
//
// Mirrors usePersonaModelDiscovery's canDiscoverModelOptions logic:
// open && modelFieldVisible && selectedRuntime?.availability === "available"
// && discoveryAgentCommand !== null && ...
const agentCommand = "buzz-agent"; // short name
const runtimes = [
{
id: "buzz-agent",
command: "/Applications/Buzz.app/Contents/MacOS/buzz-agent",
availability: "available",
defaultArgs: [],
},
];
// Step 1: derive selectedRuntimeId (the fix).
const selectedRuntimeId = deriveSelectedRuntimeId(agentCommand, runtimes);
// Step 2: resolve selectedRuntime from the catalog.
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
// Step 3: derive discoveryAgentCommand (mirrors usePersonaModelDiscovery:85-87).
const discoveryAgentCommand = selectedRuntime?.command?.trim()
? selectedRuntime.command
: null;
// Step 4: evaluate the discovery gate (mirrors :88-93, simplified).
const open = true;
const modelFieldVisible = true;
const isCustomProviderEditing = false;
const trimmedProvider = "databricks_v2";
const canDiscoverModelOptions =
open &&
modelFieldVisible &&
selectedRuntime?.availability === "available" &&
discoveryAgentCommand !== null &&
(!isCustomProviderEditing || trimmedProvider.length > 0);
assert.equal(
selectedRuntimeId,
"buzz-agent",
"id-fallback must resolve selectedRuntimeId to buzz-agent",
);
assert.ok(
selectedRuntime !== undefined,
"selectedRuntime must resolve once selectedRuntimeId is correct",
);
assert.equal(
discoveryAgentCommand,
"/Applications/Buzz.app/Contents/MacOS/buzz-agent",
"discoveryAgentCommand must be the resolved path from the catalog entry",
);
assert.equal(
canDiscoverModelOptions,
true,
"discovery gate must pass for inherited buzz-agent with databricks_v2 provider once id-fallback resolves the runtime",
);
});
test("editAgent_bugA_unknownCommandStillFallsBackToCustom", () => {
// An agentCommand that matches neither catalog command nor catalog id must
// still produce "custom" — the id-fallback must not introduce false positives.
const agentCommand = "/some/custom/binary"; // not in catalog
const runtimes = [
{
id: "buzz-agent",
command: "/Applications/Buzz.app/Contents/MacOS/buzz-agent",
availability: "available",
defaultArgs: [],
},
];
const selectedRuntimeId = deriveSelectedRuntimeId(agentCommand, runtimes);
assert.equal(
selectedRuntimeId,
"custom",
"unknown command/id must still fall back to 'custom'",
);
});

View File

@@ -574,6 +574,7 @@ export type UpdateManagedAgentInput = {
pubkey: string;
name?: string;
model?: string | null;
provider?: string | null;
systemPrompt?: string | null;
mcpToolsets?: string | null;
/** Absent = don't touch. Present = replace the env_vars map entirely. */