mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'",
|
||||
);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user