mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): add API key field, global default indicators, and collapsed advanced (#1875)
This commit is contained in:
@@ -479,7 +479,9 @@ const overrides = new Map([
|
||||
// +2 provider-aware effort: model/provider props threaded to BuzzAgentModelTuningFields.
|
||||
// +15 provider/model dropdown fixes: useBakedBuildEnvKeysQuery + hideProviderIds
|
||||
// for Databricks v1 gate; prospectiveRuntimeId default fallback for builtins.
|
||||
["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1180],
|
||||
// PR-B moves default/API-key derivation into shared hooks; the explicit
|
||||
// hidden-key projection keeps the top-level secret out of Advanced rows.
|
||||
["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1195],
|
||||
// AgentDefinitionDialog grew past 1000 with the following load-bearing fixes:
|
||||
// isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models);
|
||||
// runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
discoverGitBashPrerequisite,
|
||||
discoverManagedAgentPrereqs,
|
||||
getAgentConfigSurface,
|
||||
getBakedBuildEnv,
|
||||
getBakedBuildEnvKeys,
|
||||
getChannelMembers,
|
||||
getManagedAgentLog,
|
||||
@@ -863,6 +864,22 @@ export function useRuntimeFileConfigQuery(
|
||||
}
|
||||
|
||||
export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const;
|
||||
export const bakedBuildEnvQueryKey = ["baked-build-env"] as const;
|
||||
|
||||
/**
|
||||
* Query safely displayable baked build env entries. The backend masks secrets,
|
||||
* so this is only used for inherited provider/model/effort labels.
|
||||
*/
|
||||
export function useBakedBuildEnvQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: bakedBuildEnvQueryKey,
|
||||
queryFn: () => getBakedBuildEnv(),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: Infinity,
|
||||
refetchInterval: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the key names of baked build env vars.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PersonaDropdownField } from "./PersonaDropdownField";
|
||||
import type { EnvVarsValue } from "./EnvVarsEditor";
|
||||
import { PersonaAdvancedFields } from "./PersonaAdvancedFields";
|
||||
import { PersonaModelField } from "./PersonaModelField";
|
||||
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
|
||||
import {
|
||||
canSubmitPersonaDialog,
|
||||
formatPersonaNamePoolText,
|
||||
@@ -67,8 +68,12 @@ import {
|
||||
usePersonaModelDiscovery,
|
||||
} from "./usePersonaModelDiscovery";
|
||||
import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks";
|
||||
import { useGlobalAgentConfig } from "../useGlobalAgentConfig";
|
||||
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
|
||||
import {
|
||||
getBakedModelInheritLabel,
|
||||
getBakedProviderInheritLabel,
|
||||
} from "./bakedEnvHelpers";
|
||||
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
|
||||
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
|
||||
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
|
||||
|
||||
type AgentDefinitionDialogProps = {
|
||||
@@ -147,7 +152,6 @@ export function AgentDefinitionDialog({
|
||||
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
|
||||
const [isAvatarUploadPending, setIsAvatarUploadPending] =
|
||||
React.useState(false);
|
||||
const { globalConfig } = useGlobalAgentConfig();
|
||||
const defaultRuntime = React.useMemo(
|
||||
() => getDefaultPersonaRuntime(runtimes),
|
||||
[runtimes],
|
||||
@@ -184,12 +188,10 @@ export function AgentDefinitionDialog({
|
||||
setBehaviorDraft(nextBehaviorDraft);
|
||||
setNamePoolText(nextNamePoolText);
|
||||
setEnvVars(nextEnvVars);
|
||||
setShowAdvancedFields(
|
||||
nextNamePoolText.trim().length > 0 ||
|
||||
Object.keys(nextEnvVars).length > 0 ||
|
||||
nextBehaviorDraft.respondTo !== null ||
|
||||
nextBehaviorDraft.parallelism.trim().length > 0,
|
||||
);
|
||||
// Item 5: collapsed by default in edit mode — only expand if a non-default
|
||||
// behavior value demands attention. Having env vars or a name pool is not
|
||||
// sufficient reason to auto-open.
|
||||
setShowAdvancedFields(false);
|
||||
setIsAvatarUploadPending(false);
|
||||
isRuntimeAutoSeededRef.current = false;
|
||||
hasSeededForOpenRef.current = false;
|
||||
@@ -243,13 +245,9 @@ export function AgentDefinitionDialog({
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (
|
||||
!initialValues ||
|
||||
!canSubmitPersonaDialog({ displayName, isPending }) ||
|
||||
isAvatarUploadPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// D1: the same localModeSatisfied gate as canSubmit prevents form-submit
|
||||
// (Enter) from bypassing a missing credential.
|
||||
if (!initialValues || !localModeSatisfied || !canSubmit) return;
|
||||
|
||||
const {
|
||||
runtime: runtimeForSubmit,
|
||||
@@ -320,18 +318,28 @@ export function AgentDefinitionDialog({
|
||||
// locked rows in the env vars editor.
|
||||
// File-layer config for the selected runtime (e.g. goose config.yaml).
|
||||
// Used to silence requirements already satisfied there.
|
||||
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, {
|
||||
enabled: open,
|
||||
});
|
||||
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
const { data: runtimeFileConfig, isLoading: fileConfigLoading } =
|
||||
useRuntimeFileConfigQuery(runtime, { enabled: open });
|
||||
const {
|
||||
advancedInheritedSummary,
|
||||
globalConfig,
|
||||
inheritedDefaults: {
|
||||
provider: inheritedProviderDefault,
|
||||
model: inheritedModelDefault,
|
||||
},
|
||||
inheritedEnvVars: inheritedEnvVarsForAdvanced,
|
||||
} = useAgentDialogDefaults({ open });
|
||||
const { data: bakedEnvKeys, isLoading: bakedLoading } =
|
||||
useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
const credentialSettled = !fileConfigLoading && !bakedLoading;
|
||||
const localModeGate = React.useMemo(
|
||||
() =>
|
||||
computeLocalModeGate({
|
||||
bakedEnvKeys,
|
||||
envVars,
|
||||
globalEnvVars: globalConfig.env_vars,
|
||||
globalProvider: globalConfig.provider ?? "",
|
||||
globalModel: globalConfig.model ?? "",
|
||||
globalProvider: inheritedProviderDefault.value,
|
||||
globalModel: inheritedModelDefault.value,
|
||||
isProviderMode: false,
|
||||
model,
|
||||
provider: trimmedProvider,
|
||||
@@ -344,8 +352,8 @@ export function AgentDefinitionDialog({
|
||||
createRunOnMesh,
|
||||
envVars,
|
||||
globalConfig.env_vars,
|
||||
globalConfig.provider,
|
||||
globalConfig.model,
|
||||
inheritedModelDefault.value,
|
||||
inheritedProviderDefault.value,
|
||||
model,
|
||||
trimmedProvider,
|
||||
runtime,
|
||||
@@ -354,13 +362,36 @@ export function AgentDefinitionDialog({
|
||||
);
|
||||
// requiredEnvKeys: the gate already handles baked-, global-, and file-
|
||||
// satisfied keys so no further filtering is needed.
|
||||
const { requiredEnvKeys, missingNormalizedFields } = localModeGate;
|
||||
const { requiredEnvKeys } = localModeGate;
|
||||
// D1: single boolean for both canSubmit and handleSubmit — never recompose.
|
||||
const localModeSatisfied = localModeGate.satisfied;
|
||||
// Effective provider: agent value → global fallback → file fallback.
|
||||
// Mirrors the chain inside computeLocalModeGate so model-option scoping and
|
||||
// model requiredness are consistent with the readiness gate.
|
||||
const fileProvider = runtimeFileConfig?.provider?.trim() ?? "";
|
||||
const effectiveProvider =
|
||||
trimmedProvider || (globalConfig.provider ?? "").trim() || fileProvider;
|
||||
trimmedProvider || inheritedProviderDefault.value || fileProvider;
|
||||
// D2: the top-level API key owns display while the full gate remains intact.
|
||||
const apiKeyFieldState = useProviderApiKeyFieldState({
|
||||
bakedEnvKeys,
|
||||
effectiveEnvVars: envVars,
|
||||
envVars,
|
||||
fileSatisfiedEnvKeys: localModeGate.fileSatisfiedEnvKeys,
|
||||
globalEnvVars: globalConfig.env_vars,
|
||||
open,
|
||||
provider: effectiveProvider,
|
||||
requiredEnvKeys,
|
||||
satisfactionSettled: credentialSettled,
|
||||
setShowAdvancedFields,
|
||||
});
|
||||
const {
|
||||
advancedRequiredEnvKeys,
|
||||
inheritedLabel: apiKeyInheritedLabel,
|
||||
isInherited: apiKeyIsInherited,
|
||||
isRequired: apiKeyIsRequired,
|
||||
secretEnvVar: topLevelSecretEnvVar,
|
||||
value: apiKeyValue,
|
||||
} = apiKeyFieldState;
|
||||
// Provider required-ness is a static property of the runtime — it does not
|
||||
// change based on whether the field is currently filled. Using the dynamic
|
||||
// missingNormalizedFields check would flip the asterisk off once a value is
|
||||
@@ -387,39 +418,11 @@ export function AgentDefinitionDialog({
|
||||
// Crash-loop guard, create AND edit: an empty allowlist would crash
|
||||
// every instance minted from this definition at startup.
|
||||
personaBehaviorDraftValid(behaviorDraft) &&
|
||||
missingNormalizedFields.length === 0 &&
|
||||
// D1: localModeSatisfied covers both missingNormalizedFields AND
|
||||
// missingEnvKeys — credential env keys now block submit, not just display.
|
||||
localModeSatisfied &&
|
||||
!isAvatarUploadPending;
|
||||
|
||||
// Auto-expand the Advanced section once per dialog-open cycle when required
|
||||
// env keys are present, so the user sees a clear signal that action is
|
||||
// needed (e.g. provider API key required). Does not re-open if the user
|
||||
// manually collapses the section afterward.
|
||||
const hasAutoOpenedAdvancedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
hasAutoOpenedAdvancedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (requiredEnvKeys.length > 0 && !hasAutoOpenedAdvancedRef.current) {
|
||||
hasAutoOpenedAdvancedRef.current = true;
|
||||
setShowAdvancedFields(true);
|
||||
}
|
||||
}, [open, requiredEnvKeys.length]);
|
||||
|
||||
// Auto-expand Advanced once per open when the selected runtime is buzz-agent
|
||||
// so the model-tuning knobs are immediately reachable — mirrors the agent
|
||||
// instance dialogs' behavior.
|
||||
const hasAutoOpenedForBuzzAgentRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
hasAutoOpenedForBuzzAgentRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isBuzzAgentRuntime(runtime) && !hasAutoOpenedForBuzzAgentRef.current) {
|
||||
hasAutoOpenedForBuzzAgentRef.current = true;
|
||||
setShowAdvancedFields(true);
|
||||
}
|
||||
}, [open, runtime]);
|
||||
// Merge global env as the base layer so credential keys satisfied via global
|
||||
// config are available to model discovery — same rationale as in AgentInstanceEditDialog.
|
||||
const envVarsForDiscovery = React.useMemo(
|
||||
@@ -471,13 +474,18 @@ export function AgentDefinitionDialog({
|
||||
const providerOptions = getPersonaProviderOptions(
|
||||
trimmedProvider,
|
||||
runtime,
|
||||
globalConfig.provider ?? "",
|
||||
inheritedProviderDefault.source === "global"
|
||||
? inheritedProviderDefault.value
|
||||
: "",
|
||||
hideProviderIds,
|
||||
);
|
||||
const defaultLlmProviderLabel = getDefaultLlmProviderLabel(
|
||||
runtime,
|
||||
globalConfig.provider ?? "",
|
||||
);
|
||||
const defaultLlmProviderLabel =
|
||||
inheritedProviderDefault.source === "build"
|
||||
? getBakedProviderInheritLabel(
|
||||
inheritedProviderDefault.value,
|
||||
providerOptions,
|
||||
)
|
||||
: getDefaultLlmProviderLabel(runtime, inheritedProviderDefault.value);
|
||||
const providerSelectValue = isCustomProviderEditing
|
||||
? CUSTOM_PROVIDER_DROPDOWN_VALUE
|
||||
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
|
||||
@@ -521,15 +529,20 @@ export function AgentDefinitionDialog({
|
||||
}
|
||||
const providerDropdownOptions: PersonaDropdownOption[] = [
|
||||
...providerOptions.map((option) => ({
|
||||
label: option.label,
|
||||
label: option.id === "" ? defaultLlmProviderLabel : option.label,
|
||||
value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||
})),
|
||||
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
|
||||
];
|
||||
const inheritedModelLabel =
|
||||
inheritedModelDefault.source === "build"
|
||||
? getBakedModelInheritLabel(inheritedModelDefault.value)
|
||||
: undefined;
|
||||
const modelDropdownOptions: PersonaDropdownOption[] = [
|
||||
...buildTemplateModelDropdownOptions(
|
||||
modelOptions,
|
||||
globalConfig.model ?? "",
|
||||
inheritedModelDefault.value,
|
||||
inheritedModelLabel,
|
||||
),
|
||||
...(modelDiscoveryLoading && discoveredModelOptions === null
|
||||
? [
|
||||
@@ -811,11 +824,30 @@ export function AgentDefinitionDialog({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Provider API key is now surfaced as an amber required row
|
||||
in EnvVarsEditor — no dedicated field needed. */}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
|
||||
<PersonaProviderApiKeyField
|
||||
disabled={isPending}
|
||||
isInherited={apiKeyIsInherited}
|
||||
inheritedLabel={apiKeyInheritedLabel}
|
||||
isRequired={apiKeyIsRequired}
|
||||
label={
|
||||
effectiveProvider === "anthropic"
|
||||
? "Anthropic API Key"
|
||||
: "OpenAI API Key"
|
||||
}
|
||||
onValueChange={(next) => {
|
||||
setEnvVars((prev) => ({
|
||||
...prev,
|
||||
[topLevelSecretEnvVar]: next,
|
||||
}));
|
||||
}}
|
||||
value={apiKeyValue}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{modelFieldVisible ? (
|
||||
<PersonaModelField
|
||||
@@ -850,6 +882,11 @@ export function AgentDefinitionDialog({
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{!showAdvancedFields && advancedInheritedSummary ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{advancedInheritedSummary}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{showAdvancedFields ? (
|
||||
@@ -866,15 +903,18 @@ export function AgentDefinitionDialog({
|
||||
disabled={isPending}
|
||||
envVars={envVars}
|
||||
fileSatisfiedEnvKeys={localModeGate.fileSatisfiedEnvKeys}
|
||||
inheritedEnvVars={globalConfig.env_vars}
|
||||
hiddenEnvKeys={
|
||||
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
|
||||
}
|
||||
inheritedEnvVars={inheritedEnvVarsForAdvanced}
|
||||
model={model}
|
||||
modelTuningRuntimeId={runtime}
|
||||
namePoolText={namePoolText}
|
||||
onBehaviorDraftChange={setBehaviorDraft}
|
||||
onEnvVarsChange={setEnvVars}
|
||||
onNamePoolTextChange={setNamePoolText}
|
||||
provider={provider}
|
||||
requiredEnvKeys={requiredEnvKeys}
|
||||
provider={effectiveProvider}
|
||||
requiredEnvKeys={advancedRequiredEnvKeys}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AUTO_MODEL_DROPDOWN_VALUE,
|
||||
AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
|
||||
buildTemplateModelDropdownOptions,
|
||||
CUSTOM_MODEL_DROPDOWN_VALUE,
|
||||
CUSTOM_PROVIDER_DROPDOWN_VALUE,
|
||||
formatRuntimeOptionLabel,
|
||||
@@ -51,6 +52,8 @@ import {
|
||||
} from "./personaDialogPickers";
|
||||
import {
|
||||
computeEditAgentFormValidity,
|
||||
envVarsEqual,
|
||||
isEditAgentProviderSaveValid,
|
||||
resolveAgentCommandUpdate,
|
||||
resolveInheritedRuntimeSubmission,
|
||||
resolveRuntimeProviderCapability,
|
||||
@@ -70,8 +73,14 @@ import {
|
||||
MODEL_DISCOVERY_LOADING_VALUE,
|
||||
usePersonaModelDiscovery,
|
||||
} from "./usePersonaModelDiscovery";
|
||||
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
|
||||
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
|
||||
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
|
||||
import {
|
||||
getBakedModelInheritLabel,
|
||||
getBakedProviderInheritLabel,
|
||||
} from "./bakedEnvHelpers";
|
||||
import { getProviderApiKeyEnvVar } from "./personaDialogPickers";
|
||||
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
|
||||
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
|
||||
|
||||
const ADVANCED_FIELDS_MOTION_TRANSITION = {
|
||||
duration: 0.18,
|
||||
@@ -237,10 +246,6 @@ export function AgentInstanceEditDialog({
|
||||
return options;
|
||||
}, [sortedRuntimes, selectedRuntimeId]);
|
||||
|
||||
const llmProviderFieldVisible = runtimeSupportsLlmProviderSelection(
|
||||
selectedRuntime?.id ?? selectedRuntimeId,
|
||||
);
|
||||
|
||||
// Resolve the dialog-opening command as the catalog loads. Edit-state runtime
|
||||
// ids mutate during selection changes and cannot identify the original state.
|
||||
const originalRuntimeSupportsProvider = React.useMemo(() => {
|
||||
@@ -251,60 +256,6 @@ export function AgentInstanceEditDialog({
|
||||
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
|
||||
}, [runtimes, originalAgentCommand]);
|
||||
|
||||
// One-shot focus: when the dialog opens from a card deep-link, scroll and
|
||||
// focus the relevant field. The effect re-runs when `llmProviderFieldVisible`
|
||||
// changes so a provider-field focus request fires once the field materializes
|
||||
// (the runtime catalog may still be loading at click time). A one-shot fired
|
||||
// ref prevents re-focusing on unrelated re-renders after the target is ready.
|
||||
const normalizedFieldFocusFiredRef = React.useRef(false);
|
||||
// Reset the fired guard whenever the focus request changes (new open, new
|
||||
// focus target, or dialog switched to a different agent).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — reset guard on these three; llmProviderFieldVisible drives the focus attempt below
|
||||
React.useEffect(() => {
|
||||
normalizedFieldFocusFiredRef.current = false;
|
||||
}, [open, initialFocus, agent.pubkey]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — llmProviderFieldVisible is the availability signal that re-triggers the focus attempt; agent.pubkey handles agent-switch
|
||||
React.useEffect(() => {
|
||||
if (!open || !initialFocus) return;
|
||||
if (initialFocus.type !== "normalized_field") return;
|
||||
if (normalizedFieldFocusFiredRef.current) return;
|
||||
|
||||
// For "provider" focus: the provider dropdown is only rendered when
|
||||
// llmProviderFieldVisible is true (runtime catalog resolved). Bail until
|
||||
// it materializes — this effect re-runs when llmProviderFieldVisible flips.
|
||||
const targetId =
|
||||
initialFocus.field === "provider"
|
||||
? "edit-agent-llm-provider"
|
||||
: "edit-agent-model";
|
||||
const el = document.getElementById(targetId);
|
||||
// PersonaDropdownField renders a <button> (DropdownMenuTrigger), not a
|
||||
// native <select> — guard against HTMLElement (focus + scrollIntoView
|
||||
// both exist on HTMLElement).
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
normalizedFieldFocusFiredRef.current = true;
|
||||
|
||||
const id = requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
el.focus();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open, initialFocus, agent.pubkey, llmProviderFieldVisible]);
|
||||
// env_key is handled by EnvVarsEditor via focusKey prop below.
|
||||
|
||||
const providerForDiscovery = llmProviderFieldVisible ? provider : "";
|
||||
const normalizedConfig = configSurfaceQuery.data?.normalized;
|
||||
const modelRequired = isMissingRequiredDropdownField(
|
||||
normalizedConfig?.model,
|
||||
model,
|
||||
);
|
||||
const providerRequired = isMissingRequiredDropdownField(
|
||||
normalizedConfig?.provider,
|
||||
provider,
|
||||
);
|
||||
|
||||
// The runtime id that will actually be active after submit. When inheriting,
|
||||
// resolve from the LINKED PERSONA's runtime — that is what will run once the
|
||||
// override is cleared. Deriving from agent.agentCommand here is wrong for a
|
||||
@@ -343,6 +294,41 @@ export function AgentInstanceEditDialog({
|
||||
selectedRuntimeId,
|
||||
]);
|
||||
|
||||
const llmProviderFieldVisible =
|
||||
runtimeSupportsLlmProviderSelection(prospectiveRuntimeId);
|
||||
|
||||
// One-shot focus: when the dialog opens from a card deep-link, scroll and
|
||||
// focus the relevant field. The effect re-runs when `llmProviderFieldVisible`
|
||||
// changes so a provider-field focus request fires once the field materializes.
|
||||
const normalizedFieldFocusFiredRef = React.useRef(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — reset guard on these three; llmProviderFieldVisible drives the focus attempt below
|
||||
React.useEffect(() => {
|
||||
normalizedFieldFocusFiredRef.current = false;
|
||||
}, [open, initialFocus, agent.pubkey]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — llmProviderFieldVisible is the availability signal that re-triggers the focus attempt; agent.pubkey handles agent-switch
|
||||
React.useEffect(() => {
|
||||
if (!open || !initialFocus) return;
|
||||
if (initialFocus.type !== "normalized_field") return;
|
||||
if (normalizedFieldFocusFiredRef.current) return;
|
||||
|
||||
const targetId =
|
||||
initialFocus.field === "provider"
|
||||
? "edit-agent-llm-provider"
|
||||
: "edit-agent-model";
|
||||
const el = document.getElementById(targetId);
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
normalizedFieldFocusFiredRef.current = true;
|
||||
|
||||
const id = requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
el.focus();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open, initialFocus, agent.pubkey, llmProviderFieldVisible]);
|
||||
|
||||
// Provider + env to PERSIST on submit — also fed to the credential gate so
|
||||
// gate, saved record, and spawn snapshot all agree on one resolved value.
|
||||
// See resolveInheritedRuntimeSubmission for the inherit/transition contract.
|
||||
@@ -371,7 +357,15 @@ export function AgentInstanceEditDialog({
|
||||
],
|
||||
);
|
||||
|
||||
const { globalConfig } = useGlobalAgentConfig();
|
||||
const {
|
||||
advancedInheritedSummary,
|
||||
globalConfig,
|
||||
inheritedDefaults: {
|
||||
provider: inheritedProviderDefault,
|
||||
model: inheritedModelDefault,
|
||||
},
|
||||
inheritedEnvVars: inheritedEnvVarsForAdvanced,
|
||||
} = useAgentDialogDefaults({ inheritedEnvVars, open });
|
||||
|
||||
// Runtime/provider-required credential state, derived from the PROSPECTIVE
|
||||
// post-submit runtime — see the hook for the inherit-transition and
|
||||
@@ -380,16 +374,20 @@ export function AgentInstanceEditDialog({
|
||||
// provider is empty (global-provider-only configs must surface required keys).
|
||||
// Pass globalEnvVars so keys satisfied by global config are excluded from
|
||||
// requiredEnvKeys and do not block Save (display and gate agree).
|
||||
const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } =
|
||||
useRequiredCredentialState({
|
||||
open,
|
||||
prospectiveRuntimeId,
|
||||
provider: inheritedSubmission.provider ?? "",
|
||||
globalProvider: globalConfig.provider ?? "",
|
||||
envVars: inheritedSubmission.envVars,
|
||||
globalEnvVars: globalConfig.env_vars,
|
||||
setShowAdvancedFields,
|
||||
});
|
||||
const {
|
||||
requiredEnvKeys,
|
||||
fileSatisfiedEnvKeys,
|
||||
requiredEnvKeyMissing,
|
||||
settled: credentialSettled,
|
||||
} = useRequiredCredentialState({
|
||||
open,
|
||||
prospectiveRuntimeId,
|
||||
provider: inheritedSubmission.provider ?? "",
|
||||
globalProvider: inheritedProviderDefault.value,
|
||||
envVars: inheritedSubmission.envVars,
|
||||
globalEnvVars: globalConfig.env_vars,
|
||||
personaEnvVars: inheritHarness ? inheritedEnvVars : undefined,
|
||||
});
|
||||
|
||||
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
|
||||
@@ -403,6 +401,10 @@ export function AgentInstanceEditDialog({
|
||||
() => ({ ...globalConfig.env_vars, ...inheritedSubmission.envVars }),
|
||||
[globalConfig.env_vars, inheritedSubmission.envVars],
|
||||
);
|
||||
const effectiveProvider =
|
||||
(inheritedSubmission.provider ?? "").trim() ||
|
||||
inheritedProviderDefault.value;
|
||||
const providerForDiscovery = llmProviderFieldVisible ? effectiveProvider : "";
|
||||
|
||||
const {
|
||||
discoveredModelOptions,
|
||||
@@ -417,31 +419,37 @@ export function AgentInstanceEditDialog({
|
||||
selectedRuntime,
|
||||
});
|
||||
|
||||
// Merge global + persona env for the inherited display hint in EnvVarsEditor
|
||||
// (inside EditAgentAdvancedFields). Persona wins over global on collision
|
||||
// (higher precedence), so persona keys shadow global for display consistency.
|
||||
const inheritedWithGlobal = React.useMemo(() => {
|
||||
return { ...globalConfig.env_vars, ...inheritedEnvVars };
|
||||
}, [globalConfig.env_vars, inheritedEnvVars]);
|
||||
|
||||
// Auto-expand Advanced whenever the prospective runtime is buzz-agent so the
|
||||
// model-tuning knobs are reachable even when no required key is missing.
|
||||
// Fires once per dialog-open cycle; does not re-open if the user manually
|
||||
// collapses the section afterward.
|
||||
const hasAutoOpenedForBuzzAgentRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
hasAutoOpenedForBuzzAgentRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isBuzzAgentRuntime(prospectiveRuntimeId) &&
|
||||
!hasAutoOpenedForBuzzAgentRef.current
|
||||
) {
|
||||
hasAutoOpenedForBuzzAgentRef.current = true;
|
||||
setShowAdvancedFields(true);
|
||||
}
|
||||
}, [open, prospectiveRuntimeId]);
|
||||
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display + auto-open.
|
||||
// The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating.
|
||||
// D2/D3: the top-level API key owns display, while the readiness gate keeps
|
||||
// the complete required-key list. The effective snapshot covers persona
|
||||
// inheritance during an instance inherit transition.
|
||||
const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider);
|
||||
const personaSatisfied =
|
||||
providerApiKeyEnvVar != null &&
|
||||
!(providerApiKeyEnvVar in envVars) &&
|
||||
(inheritedEnvVars[providerApiKeyEnvVar] ?? "").length > 0;
|
||||
const apiKeyFieldState = useProviderApiKeyFieldState({
|
||||
bakedEnvKeys,
|
||||
effectiveEnvVars: inheritedSubmission.envVars,
|
||||
envVars,
|
||||
fileSatisfiedEnvKeys,
|
||||
globalEnvVars: globalConfig.env_vars,
|
||||
open,
|
||||
personaSatisfied,
|
||||
provider: effectiveProvider,
|
||||
requiredEnvKeys,
|
||||
satisfactionSettled: credentialSettled,
|
||||
setShowAdvancedFields,
|
||||
});
|
||||
const {
|
||||
advancedRequiredEnvKeys,
|
||||
inheritedLabel: apiKeyInheritedLabel,
|
||||
isInherited: apiKeyIsInherited,
|
||||
isRequired: apiKeyIsRequired,
|
||||
secretEnvVar: topLevelSecretEnvVar,
|
||||
value: apiKeyValue,
|
||||
} = apiKeyFieldState;
|
||||
|
||||
// Clear model when provider scope changes and current model is no longer valid.
|
||||
React.useEffect(() => {
|
||||
@@ -572,7 +580,7 @@ export function AgentInstanceEditDialog({
|
||||
llmProviderFieldVisible,
|
||||
currentProvider: provider,
|
||||
originalProvider: agent.provider,
|
||||
globalProvider: globalConfig.provider,
|
||||
globalProvider: inheritedProviderDefault.value,
|
||||
originalRuntimeSupportsProvider,
|
||||
});
|
||||
|
||||
@@ -681,9 +689,9 @@ export function AgentInstanceEditDialog({
|
||||
? null
|
||||
: undefined
|
||||
: undefined, // "unknown" → omit always
|
||||
envVars: envVarsChanged(submitEnvVars, agent.envVars)
|
||||
? submitEnvVars
|
||||
: undefined,
|
||||
envVars: envVarsEqual(submitEnvVars, agent.envVars)
|
||||
? undefined
|
||||
: submitEnvVars,
|
||||
respondTo: respondTo !== agent.respondTo ? respondTo : undefined,
|
||||
// The allowlist is preserved across mode toggles in local UI state
|
||||
// (so a user can flip away from allowlist and back without losing
|
||||
@@ -740,12 +748,32 @@ export function AgentInstanceEditDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Model field derived state
|
||||
// Model and provider field derived state
|
||||
const trimmedModel = model.trim();
|
||||
const normalizedConfig = configSurfaceQuery.data?.normalized;
|
||||
const modelRequired = isMissingRequiredDropdownField(
|
||||
normalizedConfig?.model,
|
||||
model,
|
||||
);
|
||||
const providerRequired = isMissingRequiredDropdownField(
|
||||
normalizedConfig?.provider,
|
||||
provider,
|
||||
);
|
||||
const inheritedModelLabel =
|
||||
inheritedModelDefault.source === "build"
|
||||
? getBakedModelInheritLabel(inheritedModelDefault.value)
|
||||
: getDefaultLlmModelLabel(inheritedModelDefault.value);
|
||||
const staticModelOptions: readonly PersonaModelOption[] = [
|
||||
{ id: "", label: getDefaultLlmModelLabel(globalConfig.model ?? "") },
|
||||
{ id: "", label: inheritedModelLabel },
|
||||
];
|
||||
const effectiveModelOptions = discoveredModelOptions ?? staticModelOptions;
|
||||
const effectiveModelOptions = buildTemplateModelDropdownOptions(
|
||||
discoveredModelOptions ?? staticModelOptions,
|
||||
inheritedModelDefault.value,
|
||||
inheritedModelLabel,
|
||||
).map(({ label, value }) => ({
|
||||
id: value === AUTO_MODEL_DROPDOWN_VALUE ? "" : value,
|
||||
label,
|
||||
}));
|
||||
const isModelCustom = !hasPersonaModelOption(
|
||||
effectiveModelOptions,
|
||||
trimmedModel,
|
||||
@@ -772,8 +800,6 @@ export function AgentInstanceEditDialog({
|
||||
: []),
|
||||
{ label: "Custom model...", value: CUSTOM_MODEL_DROPDOWN_VALUE },
|
||||
];
|
||||
|
||||
// Provider field derived state
|
||||
const trimmedProvider = provider.trim();
|
||||
const hideProviderIds = React.useMemo(
|
||||
() =>
|
||||
@@ -785,7 +811,9 @@ export function AgentInstanceEditDialog({
|
||||
const providerOptions = getPersonaProviderOptions(
|
||||
trimmedProvider,
|
||||
selectedRuntime?.id ?? "",
|
||||
globalConfig.provider ?? "",
|
||||
inheritedProviderDefault.source === "global"
|
||||
? inheritedProviderDefault.value
|
||||
: "",
|
||||
hideProviderIds,
|
||||
);
|
||||
const providerSelectValue = isCustomProviderEditing
|
||||
@@ -793,7 +821,13 @@ export function AgentInstanceEditDialog({
|
||||
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
|
||||
const providerDropdownOptions: PersonaDropdownOption[] = [
|
||||
...providerOptions.map((option) => ({
|
||||
label: option.label,
|
||||
label:
|
||||
option.id === "" && inheritedProviderDefault.source === "build"
|
||||
? getBakedProviderInheritLabel(
|
||||
inheritedProviderDefault.value,
|
||||
providerOptions,
|
||||
)
|
||||
: option.label,
|
||||
value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||
})),
|
||||
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
|
||||
@@ -984,6 +1018,27 @@ export function AgentInstanceEditDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
|
||||
<PersonaProviderApiKeyField
|
||||
disabled={updateMutation.isPending}
|
||||
isInherited={apiKeyIsInherited}
|
||||
inheritedLabel={apiKeyInheritedLabel}
|
||||
isRequired={apiKeyIsRequired}
|
||||
label={
|
||||
effectiveProvider === "anthropic"
|
||||
? "Anthropic API Key"
|
||||
: "OpenAI API Key"
|
||||
}
|
||||
onValueChange={(next) => {
|
||||
setEnvVars((prev) => ({
|
||||
...prev,
|
||||
[topLevelSecretEnvVar]: next,
|
||||
}));
|
||||
}}
|
||||
value={apiKeyValue}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
@@ -1056,6 +1111,11 @@ export function AgentInstanceEditDialog({
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{!showAdvancedFields && advancedInheritedSummary ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{advancedInheritedSummary}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{showAdvancedFields ? (
|
||||
@@ -1075,20 +1135,23 @@ export function AgentInstanceEditDialog({
|
||||
disabled={updateMutation.isPending}
|
||||
envVars={envVars}
|
||||
fileSatisfiedEnvKeys={fileSatisfiedEnvKeys}
|
||||
hiddenEnvKeys={
|
||||
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
|
||||
}
|
||||
focusKey={
|
||||
initialFocus?.type === "env_key"
|
||||
? initialFocus.key
|
||||
: undefined
|
||||
}
|
||||
inheritedEnvVars={inheritedWithGlobal}
|
||||
inheritedEnvVars={inheritedEnvVarsForAdvanced}
|
||||
inheritHarness={inheritHarness}
|
||||
linkedPersona={linkedPersona}
|
||||
model={model}
|
||||
model={inheritedSubmission.model ?? ""}
|
||||
modelTuningRuntimeId={prospectiveRuntimeId}
|
||||
parallelism={parallelism}
|
||||
provider={provider}
|
||||
provider={effectiveProvider}
|
||||
relayUrl={relayUrl}
|
||||
requiredEnvKeys={requiredEnvKeys}
|
||||
requiredEnvKeys={advancedRequiredEnvKeys}
|
||||
selectedRuntimeId={selectedRuntimeId}
|
||||
systemPrompt={systemPrompt}
|
||||
onAcpCommandChange={setAcpCommand}
|
||||
@@ -1118,54 +1181,3 @@ export function AgentInstanceEditDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the Save button should be enabled with respect to the
|
||||
* provider field in the instance-edit dialog.
|
||||
*
|
||||
* Rules (b): Legacy agents that were already on a provider-capable runtime
|
||||
* without a provider stay editable for unrelated fields. A no-provider agent
|
||||
* that switches into a provider-capable runtime must choose a provider or rely
|
||||
* on a global fallback.
|
||||
*
|
||||
* Save is allowed when:
|
||||
* 1. Provider field is hidden — not a provider-requiring runtime, always OK.
|
||||
* 2. An effective provider resolves (per-agent or global fallback) — OK.
|
||||
* 3. The agent never had a provider AND was already on a provider-capable
|
||||
* runtime — legacy state, don't suddenly block name/timeout edits.
|
||||
*/
|
||||
export function isEditAgentProviderSaveValid({
|
||||
llmProviderFieldVisible,
|
||||
currentProvider,
|
||||
originalProvider,
|
||||
globalProvider,
|
||||
originalRuntimeSupportsProvider,
|
||||
}: {
|
||||
llmProviderFieldVisible: boolean;
|
||||
currentProvider: string;
|
||||
originalProvider: string | null | undefined;
|
||||
globalProvider: string | null | undefined;
|
||||
originalRuntimeSupportsProvider: boolean;
|
||||
}): boolean {
|
||||
if (!llmProviderFieldVisible) return true;
|
||||
const effectiveProvider =
|
||||
currentProvider.trim() || (globalProvider ?? "").trim();
|
||||
if (effectiveProvider.length > 0) return true;
|
||||
// No effective provider. Allow only legacy no-provider agents that were
|
||||
// already on a provider-capable runtime when the dialog opened.
|
||||
const hadProvider = (originalProvider ?? "").trim().length > 0;
|
||||
return !hadProvider && originalRuntimeSupportsProvider;
|
||||
}
|
||||
|
||||
function envVarsChanged(
|
||||
a: Record<string, string>,
|
||||
b: Record<string, string>,
|
||||
): boolean {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return true;
|
||||
for (const k of aKeys) {
|
||||
if (a[k] !== b[k]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function EditAgentAdvancedFields({
|
||||
disabled,
|
||||
envVars,
|
||||
fileSatisfiedEnvKeys,
|
||||
hiddenEnvKeys = [],
|
||||
focusKey,
|
||||
inheritedEnvVars,
|
||||
inheritHarness,
|
||||
@@ -48,6 +49,7 @@ export function EditAgentAdvancedFields({
|
||||
disabled: boolean;
|
||||
envVars: EnvVarsValue;
|
||||
fileSatisfiedEnvKeys: readonly string[];
|
||||
hiddenEnvKeys?: readonly string[];
|
||||
/** When set, EnvVarsEditor scrolls and focuses this key's input on mount. */
|
||||
focusKey?: string;
|
||||
inheritedEnvVars: Record<string, string>;
|
||||
@@ -303,6 +305,7 @@ export function EditAgentAdvancedFields({
|
||||
<EnvVarsEditor
|
||||
disabled={disabled}
|
||||
fileSatisfiedKeys={fileSatisfiedEnvKeys}
|
||||
hiddenKeys={hiddenEnvKeys}
|
||||
focusKey={focusKey}
|
||||
helperText="Per-agent env vars. Override the template's vars on collision."
|
||||
inheritedFrom={inheritedEnvVars}
|
||||
|
||||
@@ -72,6 +72,17 @@ test("toRows_file_satisfied_key_excluded_from_rows", () => {
|
||||
assert.equal(rows[0].key, "USER_VAR");
|
||||
});
|
||||
|
||||
test("toRows_topLevelApiKey_excludedFromAdvancedRows", () => {
|
||||
const value = { ANTHROPIC_API_KEY: "sk-local", USER_VAR: "hello" };
|
||||
const rows = toRows(value, new Set(["ANTHROPIC_API_KEY"]));
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.key),
|
||||
["USER_VAR"],
|
||||
"the top-level API key must not duplicate as an Advanced env row",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Invariant 2: emit preserves required-key values ───────────────────────
|
||||
//
|
||||
// We test this via the pure helpers: build a row list (normal vars only),
|
||||
|
||||
@@ -139,6 +139,11 @@ type EnvVarsEditorProps = {
|
||||
* the user knows the key is covered without needing to add it here.
|
||||
*/
|
||||
fileSatisfiedKeys?: readonly string[];
|
||||
/**
|
||||
* Keys owned by a first-class field outside this editor. They remain in the
|
||||
* emitted map but never render as generic rows here.
|
||||
*/
|
||||
hiddenKeys?: readonly string[];
|
||||
/**
|
||||
* When set, scroll the matching required-key row into view and focus its
|
||||
* value input on mount. One-shot: ignored after the first render in which
|
||||
@@ -182,6 +187,7 @@ export function EnvVarsEditor({
|
||||
disabled = false,
|
||||
requiredKeys = [],
|
||||
fileSatisfiedKeys = [],
|
||||
hiddenKeys = [],
|
||||
focusKey,
|
||||
inheritedRows = [],
|
||||
inheritedRowsLabel = "build",
|
||||
@@ -192,8 +198,8 @@ export function EnvVarsEditor({
|
||||
// Keeping them out of rows is the invariant that prevents a pre-saved
|
||||
// required key from appearing as a duplicate normal editable row.
|
||||
const skipKeys = React.useMemo(
|
||||
() => new Set([...requiredKeys, ...fileSatisfiedKeys]),
|
||||
[requiredKeys, fileSatisfiedKeys],
|
||||
() => new Set([...requiredKeys, ...fileSatisfiedKeys, ...hiddenKeys]),
|
||||
[requiredKeys, fileSatisfiedKeys, hiddenKeys],
|
||||
);
|
||||
|
||||
// Local ordered row state — normal (non-special) keys only. Synced from
|
||||
@@ -232,7 +238,7 @@ export function EnvVarsEditor({
|
||||
// merge them back explicitly.
|
||||
function buildRecord(nextRows: Row[]): EnvVarsValue {
|
||||
const base: EnvVarsValue = {};
|
||||
for (const key of requiredKeys) {
|
||||
for (const key of [...requiredKeys, ...hiddenKeys]) {
|
||||
if (key in value) base[key] = value[key];
|
||||
}
|
||||
return { ...base, ...toRecord(nextRows) };
|
||||
|
||||
@@ -25,6 +25,7 @@ export function PersonaAdvancedFields({
|
||||
provider,
|
||||
requiredEnvKeys = [],
|
||||
fileSatisfiedEnvKeys = [],
|
||||
hiddenEnvKeys = [],
|
||||
}: {
|
||||
behaviorDraft: PersonaBehaviorDraft;
|
||||
disabled: boolean;
|
||||
@@ -44,6 +45,7 @@ export function PersonaAdvancedFields({
|
||||
provider?: string;
|
||||
requiredEnvKeys?: readonly string[];
|
||||
fileSatisfiedEnvKeys?: readonly string[];
|
||||
hiddenEnvKeys?: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5 pt-2">
|
||||
@@ -139,6 +141,7 @@ export function PersonaAdvancedFields({
|
||||
<EnvVarsEditor
|
||||
disabled={disabled}
|
||||
fileSatisfiedKeys={fileSatisfiedEnvKeys}
|
||||
hiddenKeys={hiddenEnvKeys}
|
||||
onChange={onEnvVarsChange}
|
||||
requiredKeys={requiredEnvKeys}
|
||||
value={envVars}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { RequiredFieldLabel } from "./personaProviderModelFields";
|
||||
import {
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
} from "./personaDialogPickers";
|
||||
|
||||
/**
|
||||
* Top-level API key pseudo-field for providers that require a secret
|
||||
* credential (anthropic → ANTHROPIC_API_KEY, openai → OPENAI_COMPAT_API_KEY).
|
||||
*
|
||||
* This is a pure view over `envVars[secretEnvVar]` — writes go through
|
||||
* `onValueChange`, which the parent routes to `setEnvVars`. No second copy
|
||||
* of the secret exists; the env-var row in Advanced and this field are the
|
||||
* same state, so sync is free.
|
||||
*
|
||||
* When the key is satisfied by an inherited layer (global, file, baked, or
|
||||
* persona snapshot), the field shows a placeholder instead of an empty
|
||||
* required field — consistent with `computeLocalModeGate`'s satisfied-key
|
||||
* logic. The inherited value is never echoed into the field.
|
||||
*/
|
||||
export function PersonaProviderApiKeyField({
|
||||
disabled,
|
||||
isInherited,
|
||||
inheritedLabel,
|
||||
isRequired,
|
||||
label,
|
||||
onValueChange,
|
||||
value,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
/** True when the key is satisfied by an inherited layer. */
|
||||
isInherited: boolean;
|
||||
/** Human-readable source of the inherited value. */
|
||||
inheritedLabel: string;
|
||||
/** True when the key is required and not satisfied anywhere. */
|
||||
isRequired: boolean;
|
||||
/** Display label, e.g. "Anthropic API Key". */
|
||||
label: string;
|
||||
onValueChange: (next: string) => void;
|
||||
/** Current agent-local value of the secret env var. */
|
||||
value: string;
|
||||
}) {
|
||||
const [showValue, setShowValue] = React.useState(false);
|
||||
const inputId = "persona-provider-api-key";
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<RequiredFieldLabel htmlFor={inputId} isRequired={isRequired}>
|
||||
{label}
|
||||
</RequiredFieldLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-2 px-3",
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn(
|
||||
"h-8 flex-1 px-0 py-0 leading-6",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="persona-provider-api-key"
|
||||
disabled={disabled}
|
||||
id={inputId}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
placeholder={isInherited ? inheritedLabel : "Paste API key…"}
|
||||
type={showValue ? "text" : "password"}
|
||||
value={value}
|
||||
/>
|
||||
<button
|
||||
aria-label={showValue ? "Hide API key" : "Show API key"}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowValue((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{showValue ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,3 +20,112 @@ export function getBakedProviderInheritLabel(
|
||||
const friendlyName = match ? match.label : bakedProviderId;
|
||||
return `${friendlyName} (inherited from build)`;
|
||||
}
|
||||
|
||||
export type InheritedDefaultSource = "build" | "global" | null;
|
||||
|
||||
export type InheritedDefault = {
|
||||
source: InheritedDefaultSource;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type BakedEnvEntry = {
|
||||
key: string;
|
||||
masked: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a dialog's inherited default with the backend's precedence:
|
||||
* persisted global config wins, and a safe baked build value fills an unset
|
||||
* global field. Masked build values are never eligible for UI labels.
|
||||
*/
|
||||
export function resolveInheritedDefault(
|
||||
globalValue: string | null | undefined,
|
||||
bakedEnv: readonly BakedEnvEntry[] | undefined,
|
||||
bakedKey: string,
|
||||
): InheritedDefault {
|
||||
const global = globalValue?.trim() ?? "";
|
||||
if (global) return { source: "global", value: global };
|
||||
|
||||
const baked = bakedEnv?.find(
|
||||
(entry) => entry.key === bakedKey && !entry.masked,
|
||||
);
|
||||
const value = baked?.value.trim() ?? "";
|
||||
return value ? { source: "build", value } : { source: null, value: "" };
|
||||
}
|
||||
|
||||
export function getBakedModelInheritLabel(bakedModelId: string): string {
|
||||
return `Inherit build default (${bakedModelId})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global env keys are arbitrary user input. Count only ordinary configuration
|
||||
* names in the collapsed summary so it never calls attention to credentials.
|
||||
*/
|
||||
export function countNonSecretInheritedEnvVars(
|
||||
envVars: Record<string, string>,
|
||||
excludedKey?: string | null,
|
||||
): number {
|
||||
return Object.entries(envVars).filter(
|
||||
([key, value]) =>
|
||||
key !== excludedKey &&
|
||||
value.length > 0 &&
|
||||
!/(?:_KEY|_TOKEN|_SECRET|_PASSWORD)$/i.test(key),
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Build a source-aware, value-safe summary for collapsed Advanced fields. */
|
||||
export function getAdvancedInheritedSummary(
|
||||
effort: InheritedDefault,
|
||||
globalEnvCount: number,
|
||||
): string {
|
||||
if (!effort.value && globalEnvCount === 0) return "";
|
||||
|
||||
const globalEnvLabel =
|
||||
globalEnvCount > 0
|
||||
? `${globalEnvCount} global env var${globalEnvCount > 1 ? "s" : ""}`
|
||||
: "";
|
||||
if (effort.source === "build" && globalEnvCount === 0) {
|
||||
return `Using build defaults: effort ${effort.value}`;
|
||||
}
|
||||
if (effort.source === "build") {
|
||||
return `Using inherited defaults: build effort ${effort.value} · ${globalEnvLabel}`;
|
||||
}
|
||||
|
||||
const parts = [
|
||||
...(effort.value ? [`effort ${effort.value}`] : []),
|
||||
...(globalEnvLabel ? [globalEnvLabel] : []),
|
||||
];
|
||||
return `Using global defaults: ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
export function getInheritedAgentDefaults(
|
||||
globalConfig: {
|
||||
env_vars: Record<string, string>;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
},
|
||||
bakedEnv: readonly BakedEnvEntry[] | undefined,
|
||||
): {
|
||||
effort: InheritedDefault;
|
||||
model: InheritedDefault;
|
||||
provider: InheritedDefault;
|
||||
} {
|
||||
return {
|
||||
provider: resolveInheritedDefault(
|
||||
globalConfig.provider,
|
||||
bakedEnv,
|
||||
"BUZZ_AGENT_PROVIDER",
|
||||
),
|
||||
model: resolveInheritedDefault(
|
||||
globalConfig.model,
|
||||
bakedEnv,
|
||||
"BUZZ_AGENT_MODEL",
|
||||
),
|
||||
effort: resolveInheritedDefault(
|
||||
globalConfig.env_vars.BUZZ_AGENT_THINKING_EFFORT,
|
||||
bakedEnv,
|
||||
"BUZZ_AGENT_THINKING_EFFORT",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
*
|
||||
* The gate computes whether required fields are present for the selected
|
||||
* runtime: when missing, it surfaces field markers (isRequired) and env-key
|
||||
* amber rows (EnvVarsEditor.requiredKeys), and the setup-listener nudge will
|
||||
* fire after spawn. The gate NO LONGER blocks the create/save button —
|
||||
* users can save with incomplete config and the nudge will guide them.
|
||||
* amber rows (EnvVarsEditor.requiredKeys). `localModeGate.satisfied` also
|
||||
* blocks create and definition-edit saves, preventing invalid agents from
|
||||
* starting with incomplete provider configuration.
|
||||
*
|
||||
* On Create there is no inherit checkbox, so selectedRuntimeId IS the
|
||||
* prospective runtime — no prospectiveRuntimeId hoist needed.
|
||||
*
|
||||
* The shared helper under test:
|
||||
* computeLocalModeGate — pure function used by field isRequired and
|
||||
* EnvVarsEditor.requiredKeys; canSubmit no longer
|
||||
* reads gate.satisfied.
|
||||
* computeLocalModeGate — pure function used by field isRequired,
|
||||
* EnvVarsEditor.requiredKeys, and the submit gate.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
@@ -26,12 +25,20 @@ import {
|
||||
getDefaultLlmModelLabel,
|
||||
getDefaultLlmProviderLabel,
|
||||
getPersonaModelOptions,
|
||||
getPersonaProviderOptions,
|
||||
getProviderApiKeyEnvVar,
|
||||
isGloballySatisfiedCredentialKey,
|
||||
requiredCredentialEnvKeys,
|
||||
runtimeSupportsLlmProviderSelection,
|
||||
} from "./personaDialogPickers.tsx";
|
||||
import { hasMissingRequiredEnvKey } from "./personaRuntimeModel.ts";
|
||||
import {
|
||||
countNonSecretInheritedEnvVars,
|
||||
getBakedModelInheritLabel,
|
||||
getAdvancedInheritedSummary,
|
||||
getBakedProviderInheritLabel,
|
||||
resolveInheritedDefault,
|
||||
} from "./bakedEnvHelpers.ts";
|
||||
|
||||
// ── Core predicate: provider-selection support ─────────────────────────────
|
||||
|
||||
@@ -561,6 +568,19 @@ test("getBakedSatisfiedEnvKeys_agentLocalSet_keyNotBakedSatisfied", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("getBakedSatisfiedEnvKeys_localEmpty_shadowsBakedValue", () => {
|
||||
const result = getBakedSatisfiedEnvKeys(
|
||||
["ANTHROPIC_API_KEY"],
|
||||
{ ANTHROPIC_API_KEY: "" },
|
||||
["ANTHROPIC_API_KEY"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result,
|
||||
[],
|
||||
"an explicit local empty string must block baked fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("getBakedSatisfiedEnvKeys_undefinedBaked_returnsEmpty", () => {
|
||||
const result = getBakedSatisfiedEnvKeys(["DATABRICKS_HOST"], {}, undefined);
|
||||
assert.deepEqual(result, []);
|
||||
@@ -877,6 +897,88 @@ test("providerDefaultLabel_globalSetWithWhitespace_trimsAndReturnsInherit", () =
|
||||
);
|
||||
});
|
||||
|
||||
// ── Baked inherited-default labels ─────────────────────────────────────────
|
||||
|
||||
test("bakedDefaults_emptyGlobal_usesBuildValuesForCreateAndEditLabels", () => {
|
||||
const bakedEnv = [
|
||||
{ key: "BUZZ_AGENT_PROVIDER", value: "databricks_v2", masked: false },
|
||||
{
|
||||
key: "BUZZ_AGENT_MODEL",
|
||||
value: "goose-claude-opus-4-8",
|
||||
masked: false,
|
||||
},
|
||||
{ key: "BUZZ_AGENT_THINKING_EFFORT", value: "high", masked: false },
|
||||
];
|
||||
const provider = resolveInheritedDefault(
|
||||
null,
|
||||
bakedEnv,
|
||||
"BUZZ_AGENT_PROVIDER",
|
||||
);
|
||||
const model = resolveInheritedDefault(null, bakedEnv, "BUZZ_AGENT_MODEL");
|
||||
const effort = resolveInheritedDefault(
|
||||
null,
|
||||
bakedEnv,
|
||||
"BUZZ_AGENT_THINKING_EFFORT",
|
||||
);
|
||||
const providerOptions = getPersonaProviderOptions("", "buzz-agent");
|
||||
|
||||
assert.deepEqual(provider, { source: "build", value: "databricks_v2" });
|
||||
assert.equal(
|
||||
getBakedProviderInheritLabel(provider.value, providerOptions),
|
||||
"Databricks v2 (inherited from build)",
|
||||
);
|
||||
assert.deepEqual(model, {
|
||||
source: "build",
|
||||
value: "goose-claude-opus-4-8",
|
||||
});
|
||||
assert.equal(
|
||||
getBakedModelInheritLabel(model.value),
|
||||
"Inherit build default (goose-claude-opus-4-8)",
|
||||
);
|
||||
assert.deepEqual(effort, { source: "build", value: "high" });
|
||||
});
|
||||
|
||||
test("bakedDefaults_explicitGlobalsOverrideBuildValues", () => {
|
||||
const bakedEnv = [
|
||||
{ key: "BUZZ_AGENT_PROVIDER", value: "databricks_v2", masked: false },
|
||||
{ key: "BUZZ_AGENT_MODEL", value: "build-model", masked: false },
|
||||
{ key: "BUZZ_AGENT_THINKING_EFFORT", value: "high", masked: false },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveInheritedDefault("anthropic", bakedEnv, "BUZZ_AGENT_PROVIDER"),
|
||||
{ source: "global", value: "anthropic" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveInheritedDefault("global-model", bakedEnv, "BUZZ_AGENT_MODEL"),
|
||||
{ source: "global", value: "global-model" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveInheritedDefault("low", bakedEnv, "BUZZ_AGENT_THINKING_EFFORT"),
|
||||
{ source: "global", value: "low" },
|
||||
);
|
||||
});
|
||||
|
||||
test("advancedSummary_excludesCredentialNamesAndValues", () => {
|
||||
const nonSecretGlobalEnvCount = countNonSecretInheritedEnvVars({
|
||||
ANTHROPIC_API_KEY: "sk-secret",
|
||||
DATABRICKS_HOST: "https://example.databricks.com",
|
||||
OTHER_TOKEN: "secret",
|
||||
});
|
||||
assert.equal(
|
||||
nonSecretGlobalEnvCount,
|
||||
1,
|
||||
"only non-secret inherited defaults belong in the collapsed summary",
|
||||
);
|
||||
assert.equal(
|
||||
getAdvancedInheritedSummary(
|
||||
{ source: "build", value: "high" },
|
||||
nonSecretGlobalEnvCount,
|
||||
),
|
||||
"Using inherited defaults: build effort high · 1 global env var",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Model-default label ────────────────────────────────────────────────────
|
||||
|
||||
test("modelDefaultLabel_noGlobal_returnsDefaultModel", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isEditAgentProviderSaveValid } from "./AgentInstanceEditDialog.tsx";
|
||||
import { isEditAgentProviderSaveValid } from "./personaRuntimeModel.ts";
|
||||
|
||||
// Shorthand for test args.
|
||||
const visible = true;
|
||||
|
||||
@@ -19,14 +19,21 @@ import { hasMissingRequiredEnvKey } from "./personaRuntimeModel.ts";
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Simulate the hook's filtered key list: allRequiredKeys minus globally-satisfied.
|
||||
* Simulate the hook's filtered key list: allRequiredKeys minus globally/persona-satisfied.
|
||||
* Delegates to `isGloballySatisfiedCredentialKey` — the same helper used by
|
||||
* `computeLocalModeGate` and `useRequiredCredentialState` — so this test
|
||||
* exercises production semantics rather than a local approximation.
|
||||
*/
|
||||
function filterRequiredKeys(allKeys, globalEnvVars, envVars = {}) {
|
||||
function filterRequiredKeys(
|
||||
allKeys,
|
||||
globalEnvVars,
|
||||
envVars = {},
|
||||
personaEnvVars = {},
|
||||
) {
|
||||
return allKeys.filter(
|
||||
(key) => !isGloballySatisfiedCredentialKey(key, globalEnvVars, envVars),
|
||||
(key) =>
|
||||
!isGloballySatisfiedCredentialKey(key, globalEnvVars, envVars) &&
|
||||
!isGloballySatisfiedCredentialKey(key, personaEnvVars, envVars),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,3 +192,44 @@ test("editAgent_globalSatisfied_agentLocalKeyAbsent_stillSilenced", () => {
|
||||
"requiredEnvKeyMissing must be false when global key is not shadowed",
|
||||
);
|
||||
});
|
||||
|
||||
// ── F2: persona-satisfied credential keys ──────────────────────────────────
|
||||
|
||||
test("editAgent_personaSatisfied_keyNotRequired", () => {
|
||||
const allKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic");
|
||||
const personaEnvVars = { ANTHROPIC_API_KEY: "sk-persona" };
|
||||
|
||||
const filteredKeys = filterRequiredKeys(allKeys, {}, {}, personaEnvVars);
|
||||
assert.equal(
|
||||
filteredKeys.includes("ANTHROPIC_API_KEY"),
|
||||
false,
|
||||
"persona-satisfied key must be excluded from required keys",
|
||||
);
|
||||
assert.equal(
|
||||
hasMissingRequiredEnvKey(filteredKeys, {}),
|
||||
false,
|
||||
"requiredEnvKeyMissing must be false when persona satisfies the key",
|
||||
);
|
||||
});
|
||||
|
||||
test("editAgent_personaSatisfied_agentLocalEmpty_stillRequired", () => {
|
||||
const allKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic");
|
||||
const personaEnvVars = { ANTHROPIC_API_KEY: "sk-persona" };
|
||||
const perAgentEnvVars = { ANTHROPIC_API_KEY: "" };
|
||||
|
||||
const filteredKeys = filterRequiredKeys(
|
||||
allKeys,
|
||||
{},
|
||||
perAgentEnvVars,
|
||||
personaEnvVars,
|
||||
);
|
||||
assert.ok(
|
||||
filteredKeys.includes("ANTHROPIC_API_KEY"),
|
||||
"explicit local empty must shadow persona value",
|
||||
);
|
||||
assert.equal(
|
||||
hasMissingRequiredEnvKey(filteredKeys, perAgentEnvVars),
|
||||
true,
|
||||
"requiredEnvKeyMissing must be true when local empty shadows persona",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -296,20 +296,17 @@ export function getDefaultLlmModelLabel(globalModel?: string) {
|
||||
*/
|
||||
export function buildTemplateModelDropdownOptions(
|
||||
modelOptions: readonly PersonaModelOption[],
|
||||
globalModel: string,
|
||||
inheritedModel: string,
|
||||
inheritedModelLabel = getDefaultLlmModelLabel(inheritedModel),
|
||||
): PersonaDropdownOption[] {
|
||||
const trimmedGlobal = globalModel.trim();
|
||||
const trimmedInheritedModel = inheritedModel.trim();
|
||||
const hasZeroValue = modelOptions.some((o) => o.id === "");
|
||||
const base: readonly PersonaModelOption[] =
|
||||
!hasZeroValue && trimmedGlobal.length > 0
|
||||
? [
|
||||
{ id: "", label: getDefaultLlmModelLabel(trimmedGlobal) },
|
||||
...modelOptions,
|
||||
]
|
||||
!hasZeroValue && trimmedInheritedModel.length > 0
|
||||
? [{ id: "", label: inheritedModelLabel }, ...modelOptions]
|
||||
: modelOptions;
|
||||
return base.map((option) => ({
|
||||
label:
|
||||
option.id === "" ? getDefaultLlmModelLabel(trimmedGlobal) : option.label,
|
||||
label: option.id === "" ? inheritedModelLabel : option.label,
|
||||
value: option.id || AUTO_MODEL_DROPDOWN_VALUE,
|
||||
}));
|
||||
}
|
||||
@@ -485,7 +482,9 @@ export function isGloballySatisfiedCredentialKey(
|
||||
* render an info row ("Set in goose config"). Baked env is invisible
|
||||
* infrastructure; surfacing it would be noise for users.
|
||||
*
|
||||
* **Precedence:** agent-local > baked > global > file for satisfaction.
|
||||
* **Precedence:** agent-local > baked > global > file for satisfaction. An
|
||||
* explicit local empty string is still an agent-local override, so it must NOT
|
||||
* fall through to the baked layer.
|
||||
*/
|
||||
export function getBakedSatisfiedEnvKeys(
|
||||
requiredKeys: readonly string[],
|
||||
@@ -494,9 +493,7 @@ export function getBakedSatisfiedEnvKeys(
|
||||
): string[] {
|
||||
if (!bakedEnvKeys || bakedEnvKeys.length === 0) return [];
|
||||
const bakedSet = new Set(bakedEnvKeys);
|
||||
return requiredKeys.filter(
|
||||
(key) => (envVars[key] ?? "").length === 0 && bakedSet.has(key),
|
||||
);
|
||||
return requiredKeys.filter((key) => !(key in envVars) && bakedSet.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -638,8 +635,8 @@ export function computeLocalModeGate({
|
||||
} else if (bakedSatisfiedSet.has(key)) {
|
||||
// Not in global env but covered by the baked build env — silenced.
|
||||
// Don't add to fileSatisfiedEnvKeys; baked keys produce no info row.
|
||||
} else if (fileSatisfiedKeys.has(key)) {
|
||||
// Not in Buzz env or global but present in the runtime config file.
|
||||
} else if (!(key in envVars) && fileSatisfiedKeys.has(key)) {
|
||||
// No higher-priority local override and present in the runtime config file.
|
||||
fileSatisfiedEnvKeys.push(key);
|
||||
} else {
|
||||
// Key needs a locked amber row in EnvVarsEditor (whether or not the
|
||||
|
||||
@@ -268,3 +268,35 @@ export function computeEditAgentFormValidity(
|
||||
!input.requiredEnvKeyMissing
|
||||
);
|
||||
}
|
||||
|
||||
export function isEditAgentProviderSaveValid({
|
||||
llmProviderFieldVisible,
|
||||
currentProvider,
|
||||
originalProvider,
|
||||
globalProvider,
|
||||
originalRuntimeSupportsProvider,
|
||||
}: {
|
||||
llmProviderFieldVisible: boolean;
|
||||
currentProvider: string;
|
||||
originalProvider: string | null | undefined;
|
||||
globalProvider: string | null | undefined;
|
||||
originalRuntimeSupportsProvider: boolean;
|
||||
}): boolean {
|
||||
if (!llmProviderFieldVisible) return true;
|
||||
if (currentProvider.trim() || (globalProvider ?? "").trim()) return true;
|
||||
return (
|
||||
(originalProvider ?? "").trim().length === 0 &&
|
||||
originalRuntimeSupportsProvider
|
||||
);
|
||||
}
|
||||
|
||||
export function envVarsEqual(
|
||||
a: Record<string, string>,
|
||||
b: Record<string, string>,
|
||||
): boolean {
|
||||
const aKeys = Object.keys(a);
|
||||
return (
|
||||
aKeys.length === Object.keys(b).length &&
|
||||
aKeys.every((key) => a[key] === b[key])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getProviderApiKeyFieldState } from "./providerApiKeyFieldState.ts";
|
||||
|
||||
test("providerApiKeyFieldState_missingAnthropicKey_isTopLevelRequired", () => {
|
||||
const state = getProviderApiKeyFieldState({
|
||||
bakedEnvKeys: [],
|
||||
effectiveEnvVars: {},
|
||||
envVars: {},
|
||||
globalEnvVars: {},
|
||||
provider: "anthropic",
|
||||
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
});
|
||||
|
||||
assert.equal(state.secretEnvVar, "ANTHROPIC_API_KEY");
|
||||
assert.equal(state.isRequired, true);
|
||||
assert.equal(state.isInherited, false);
|
||||
assert.deepEqual(state.advancedRequiredEnvKeys, []);
|
||||
});
|
||||
|
||||
test("providerApiKeyFieldState_globalCredential_isInheritedWithoutDuplicateAdvancedRow", () => {
|
||||
const state = getProviderApiKeyFieldState({
|
||||
bakedEnvKeys: [],
|
||||
effectiveEnvVars: {},
|
||||
envVars: {},
|
||||
globalEnvVars: { ANTHROPIC_API_KEY: "sk-global" },
|
||||
provider: "anthropic",
|
||||
requiredEnvKeys: [],
|
||||
});
|
||||
|
||||
assert.equal(state.isRequired, false);
|
||||
assert.equal(state.isInherited, true);
|
||||
assert.equal(state.inheritedLabel, "Inherited from global config");
|
||||
assert.deepEqual(state.advancedRequiredEnvKeys, []);
|
||||
});
|
||||
|
||||
test("providerApiKeyFieldState_personaCredential_isInheritedForInstanceTransition", () => {
|
||||
const state = getProviderApiKeyFieldState({
|
||||
bakedEnvKeys: [],
|
||||
effectiveEnvVars: { ANTHROPIC_API_KEY: "persona-secret" },
|
||||
envVars: {},
|
||||
globalEnvVars: {},
|
||||
personaSatisfied: true,
|
||||
provider: "anthropic",
|
||||
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
});
|
||||
|
||||
assert.equal(state.isRequired, false);
|
||||
assert.equal(state.isInherited, true);
|
||||
assert.equal(state.inheritedLabel, "Inherited from agent profile");
|
||||
});
|
||||
|
||||
test("providerApiKeyFieldState_explicitLocalEmptyShadowsGlobalAndBuild", () => {
|
||||
const state = getProviderApiKeyFieldState({
|
||||
bakedEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
effectiveEnvVars: { ANTHROPIC_API_KEY: "" },
|
||||
envVars: { ANTHROPIC_API_KEY: "" },
|
||||
globalEnvVars: { ANTHROPIC_API_KEY: "sk-global" },
|
||||
provider: "anthropic",
|
||||
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
});
|
||||
|
||||
assert.equal(state.isRequired, true);
|
||||
assert.equal(state.isInherited, false);
|
||||
assert.equal(state.inheritedLabel, "");
|
||||
});
|
||||
|
||||
test("providerApiKeyFieldState_openaiUsesCompatCredentialKey", () => {
|
||||
const state = getProviderApiKeyFieldState({
|
||||
bakedEnvKeys: [],
|
||||
effectiveEnvVars: {},
|
||||
envVars: {},
|
||||
globalEnvVars: {},
|
||||
provider: "openai",
|
||||
requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"],
|
||||
});
|
||||
|
||||
assert.equal(state.secretEnvVar, "OPENAI_COMPAT_API_KEY");
|
||||
assert.equal(state.isRequired, true);
|
||||
});
|
||||
|
||||
test("providerApiKeyFieldState_explicitLocalEmptyShadowsRuntimeConfig", () => {
|
||||
const state = getProviderApiKeyFieldState({
|
||||
bakedEnvKeys: [],
|
||||
effectiveEnvVars: { ANTHROPIC_API_KEY: "" },
|
||||
envVars: { ANTHROPIC_API_KEY: "" },
|
||||
fileSatisfiedEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
globalEnvVars: {},
|
||||
provider: "anthropic",
|
||||
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
});
|
||||
|
||||
assert.equal(state.isRequired, true);
|
||||
assert.equal(state.isInherited, false);
|
||||
});
|
||||
172
desktop/src/features/agents/ui/providerApiKeyFieldState.ts
Normal file
172
desktop/src/features/agents/ui/providerApiKeyFieldState.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { EnvVarsValue } from "./EnvVarsEditor";
|
||||
import {
|
||||
getBakedSatisfiedEnvKeys,
|
||||
getProviderApiKeyEnvVar,
|
||||
isGloballySatisfiedCredentialKey,
|
||||
} from "./personaDialogPickers";
|
||||
|
||||
export type ProviderApiKeyFieldState = {
|
||||
advancedRequiredEnvKeys: readonly string[];
|
||||
inheritedLabel: string;
|
||||
isInherited: boolean;
|
||||
isRequired: boolean;
|
||||
secretEnvVar: string | null;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the top-level API-key field from the same env snapshots that drive
|
||||
* readiness. `envVars` is the raw local record, while `effectiveEnvVars` may
|
||||
* include a persona snapshot during an instance inherit transition.
|
||||
*/
|
||||
export function getProviderApiKeyFieldState({
|
||||
bakedEnvKeys,
|
||||
effectiveEnvVars,
|
||||
envVars,
|
||||
fileSatisfiedEnvKeys = [],
|
||||
globalEnvVars,
|
||||
personaSatisfied = false,
|
||||
provider,
|
||||
requiredEnvKeys,
|
||||
}: {
|
||||
bakedEnvKeys: readonly string[] | undefined;
|
||||
effectiveEnvVars: EnvVarsValue;
|
||||
envVars: EnvVarsValue;
|
||||
fileSatisfiedEnvKeys?: readonly string[];
|
||||
globalEnvVars: EnvVarsValue;
|
||||
personaSatisfied?: boolean;
|
||||
provider: string;
|
||||
requiredEnvKeys: readonly string[];
|
||||
}): ProviderApiKeyFieldState {
|
||||
const secretEnvVar = getProviderApiKeyEnvVar(provider);
|
||||
const advancedRequiredEnvKeys = secretEnvVar
|
||||
? requiredEnvKeys.filter((key) => key !== secretEnvVar)
|
||||
: requiredEnvKeys;
|
||||
if (!secretEnvVar) {
|
||||
return {
|
||||
advancedRequiredEnvKeys,
|
||||
inheritedLabel: "",
|
||||
isInherited: false,
|
||||
isRequired: false,
|
||||
secretEnvVar: null,
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
const value = envVars[secretEnvVar] ?? "";
|
||||
const localOverride = secretEnvVar in envVars;
|
||||
const source =
|
||||
value.length > 0
|
||||
? null
|
||||
: personaSatisfied && !localOverride
|
||||
? "persona"
|
||||
: isGloballySatisfiedCredentialKey(
|
||||
secretEnvVar,
|
||||
globalEnvVars,
|
||||
effectiveEnvVars,
|
||||
)
|
||||
? "global"
|
||||
: getBakedSatisfiedEnvKeys(
|
||||
[secretEnvVar],
|
||||
effectiveEnvVars,
|
||||
bakedEnvKeys,
|
||||
).length > 0
|
||||
? "build"
|
||||
: !(secretEnvVar in effectiveEnvVars) &&
|
||||
fileSatisfiedEnvKeys.includes(secretEnvVar)
|
||||
? "file"
|
||||
: null;
|
||||
const inheritedLabel =
|
||||
source === "persona"
|
||||
? "Inherited from agent profile"
|
||||
: source === "global"
|
||||
? "Inherited from global config"
|
||||
: source === "build"
|
||||
? "Inherited from build"
|
||||
: source === "file"
|
||||
? "Set in runtime config"
|
||||
: "";
|
||||
|
||||
return {
|
||||
advancedRequiredEnvKeys,
|
||||
inheritedLabel,
|
||||
isInherited: source !== null,
|
||||
isRequired: source === null && value.length === 0,
|
||||
secretEnvVar,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProviderApiKeyFieldState({
|
||||
bakedEnvKeys,
|
||||
effectiveEnvVars,
|
||||
envVars,
|
||||
fileSatisfiedEnvKeys,
|
||||
globalEnvVars,
|
||||
open,
|
||||
personaSatisfied,
|
||||
provider,
|
||||
requiredEnvKeys,
|
||||
satisfactionSettled = true,
|
||||
setShowAdvancedFields,
|
||||
}: {
|
||||
bakedEnvKeys: readonly string[] | undefined;
|
||||
effectiveEnvVars: EnvVarsValue;
|
||||
envVars: EnvVarsValue;
|
||||
fileSatisfiedEnvKeys?: readonly string[];
|
||||
globalEnvVars: EnvVarsValue;
|
||||
open: boolean;
|
||||
personaSatisfied?: boolean;
|
||||
provider: string;
|
||||
requiredEnvKeys: readonly string[];
|
||||
satisfactionSettled?: boolean;
|
||||
setShowAdvancedFields: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}): ProviderApiKeyFieldState {
|
||||
const fieldState = React.useMemo(
|
||||
() =>
|
||||
getProviderApiKeyFieldState({
|
||||
bakedEnvKeys,
|
||||
effectiveEnvVars,
|
||||
envVars,
|
||||
fileSatisfiedEnvKeys,
|
||||
globalEnvVars,
|
||||
personaSatisfied,
|
||||
provider,
|
||||
requiredEnvKeys,
|
||||
}),
|
||||
[
|
||||
bakedEnvKeys,
|
||||
effectiveEnvVars,
|
||||
envVars,
|
||||
fileSatisfiedEnvKeys,
|
||||
globalEnvVars,
|
||||
personaSatisfied,
|
||||
provider,
|
||||
requiredEnvKeys,
|
||||
],
|
||||
);
|
||||
const hasAutoOpenedAdvancedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
hasAutoOpenedAdvancedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
satisfactionSettled &&
|
||||
fieldState.advancedRequiredEnvKeys.length > 0 &&
|
||||
!hasAutoOpenedAdvancedRef.current
|
||||
) {
|
||||
hasAutoOpenedAdvancedRef.current = true;
|
||||
setShowAdvancedFields(true);
|
||||
}
|
||||
}, [
|
||||
fieldState.advancedRequiredEnvKeys.length,
|
||||
open,
|
||||
satisfactionSettled,
|
||||
setShowAdvancedFields,
|
||||
]);
|
||||
|
||||
return fieldState;
|
||||
}
|
||||
46
desktop/src/features/agents/ui/useAgentDialogDefaults.ts
Normal file
46
desktop/src/features/agents/ui/useAgentDialogDefaults.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useBakedBuildEnvQuery } from "../hooks";
|
||||
import { useGlobalAgentConfig } from "../useGlobalAgentConfig";
|
||||
import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig";
|
||||
import {
|
||||
countNonSecretInheritedEnvVars,
|
||||
getAdvancedInheritedSummary,
|
||||
getInheritedAgentDefaults,
|
||||
} from "./bakedEnvHelpers";
|
||||
|
||||
export function useAgentDialogDefaults({
|
||||
inheritedEnvVars = {},
|
||||
open,
|
||||
}: {
|
||||
inheritedEnvVars?: Record<string, string>;
|
||||
open: boolean;
|
||||
}) {
|
||||
const { globalConfig } = useGlobalAgentConfig();
|
||||
const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: open });
|
||||
const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv);
|
||||
const effectiveInheritedEnvVars = React.useMemo(
|
||||
() => ({
|
||||
...globalConfig.env_vars,
|
||||
...inheritedEnvVars,
|
||||
...(inheritedDefaults.effort.value
|
||||
? { [BUZZ_AGENT_THINKING_EFFORT]: inheritedDefaults.effort.value }
|
||||
: {}),
|
||||
}),
|
||||
[globalConfig.env_vars, inheritedDefaults.effort.value, inheritedEnvVars],
|
||||
);
|
||||
const advancedInheritedSummary = getAdvancedInheritedSummary(
|
||||
inheritedDefaults.effort,
|
||||
countNonSecretInheritedEnvVars(
|
||||
globalConfig.env_vars,
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
advancedInheritedSummary,
|
||||
globalConfig,
|
||||
inheritedDefaults,
|
||||
inheritedEnvVars: effectiveInheritedEnvVars,
|
||||
};
|
||||
}
|
||||
@@ -21,11 +21,12 @@ export interface RequiredCredentialState {
|
||||
fileSatisfiedEnvKeys: string[];
|
||||
/** Whether any required env key is still missing (blocks Save). */
|
||||
requiredEnvKeyMissing: boolean;
|
||||
/** True once all async satisfaction sources (baked, file config) have resolved. */
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the runtime/provider-required credential state and keep the Advanced
|
||||
* section's required-credential row visible when a key is newly missing.
|
||||
* Compute runtime/provider-required credential state for the Edit Agent dialog.
|
||||
*
|
||||
* All keys are derived from the PROSPECTIVE post-submit runtime (not the
|
||||
* current dropdown). On an inherit transition (claude→buzz-agent or the
|
||||
@@ -33,12 +34,8 @@ export interface RequiredCredentialState {
|
||||
* unblock Save; using the prospective id keeps the gate honest about what will
|
||||
* actually be saved.
|
||||
*
|
||||
* The `EnvVarsEditor` (and its amber required-key row) lives inside the
|
||||
* collapsed-by-default Advanced section, so a provider change that newly
|
||||
* requires a key would otherwise leave the row unmounted while Save is disabled
|
||||
* with no on-screen reason. This hook auto-expands Advanced on the
|
||||
* missing→present-requirement transition, so the user can still collapse it
|
||||
* again once the key is filled.
|
||||
* The caller owns the visibility policy: top-level API-key fields are already
|
||||
* visible, while non-secret Advanced-only keys can open that section.
|
||||
*
|
||||
* `globalProvider` is used as the fallback when the per-agent provider is
|
||||
* empty — without it, a global-provider-only config produces no required keys
|
||||
@@ -60,7 +57,10 @@ export function useRequiredCredentialState(params: {
|
||||
/** Global config env vars; keys satisfied here are excluded from required
|
||||
* rows and do not block Save — mirrors the agent→global→file precedence. */
|
||||
globalEnvVars?: Record<string, string>;
|
||||
setShowAdvancedFields: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Persona env vars; keys satisfied here are excluded from required rows
|
||||
* (mirrors backend readiness.rs which layers live_persona_env under record
|
||||
* env). An explicit local empty shadows the persona value. */
|
||||
personaEnvVars?: Record<string, string>;
|
||||
}): RequiredCredentialState {
|
||||
const {
|
||||
open,
|
||||
@@ -69,7 +69,7 @@ export function useRequiredCredentialState(params: {
|
||||
globalProvider = "",
|
||||
envVars,
|
||||
globalEnvVars = {},
|
||||
setShowAdvancedFields,
|
||||
personaEnvVars = {},
|
||||
} = params;
|
||||
|
||||
const providerForRequiredKeys = runtimeSupportsLlmProviderSelection(
|
||||
@@ -78,14 +78,14 @@ export function useRequiredCredentialState(params: {
|
||||
? provider.trim() || globalProvider.trim()
|
||||
: "";
|
||||
|
||||
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(
|
||||
prospectiveRuntimeId,
|
||||
{ enabled: open },
|
||||
);
|
||||
const { data: runtimeFileConfig, isLoading: fileConfigLoading } =
|
||||
useRuntimeFileConfigQuery(prospectiveRuntimeId, { enabled: open });
|
||||
|
||||
const { data: bakedEnvKeys, isFetched: bakedEnvKeysFetched } =
|
||||
const { data: bakedEnvKeys, isLoading: bakedLoading } =
|
||||
useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
|
||||
const settled = !fileConfigLoading && !bakedLoading;
|
||||
|
||||
// All required keys for this runtime + provider combination.
|
||||
const allRequiredKeys = React.useMemo(
|
||||
() =>
|
||||
@@ -103,7 +103,7 @@ export function useRequiredCredentialState(params: {
|
||||
if (!runtimeFileConfig) return [] as string[];
|
||||
return allRequiredKeys.filter(
|
||||
(key) =>
|
||||
(envVars[key] ?? "").length === 0 &&
|
||||
!(key in envVars) &&
|
||||
!bakedSatisfiedKeys.includes(key) &&
|
||||
runtimeFileConfig.satisfiedEnvKeys.includes(key),
|
||||
);
|
||||
@@ -115,16 +115,15 @@ export function useRequiredCredentialState(params: {
|
||||
(key) =>
|
||||
!bakedSatisfiedKeys.includes(key) &&
|
||||
!fileSatisfiedEnvKeys.includes(key) &&
|
||||
// isGloballySatisfiedCredentialKey returns true when global has the key
|
||||
// AND agent-local has NOT explicitly shadowed it with "" — same semantics
|
||||
// as computeLocalModeGate, preventing create/edit gate drift.
|
||||
!isGloballySatisfiedCredentialKey(key, globalEnvVars, envVars),
|
||||
!isGloballySatisfiedCredentialKey(key, globalEnvVars, envVars) &&
|
||||
!isGloballySatisfiedCredentialKey(key, personaEnvVars, envVars),
|
||||
),
|
||||
[
|
||||
allRequiredKeys,
|
||||
bakedSatisfiedKeys,
|
||||
fileSatisfiedEnvKeys,
|
||||
globalEnvVars,
|
||||
personaEnvVars,
|
||||
envVars,
|
||||
],
|
||||
);
|
||||
@@ -134,26 +133,10 @@ export function useRequiredCredentialState(params: {
|
||||
[requiredEnvKeys, envVars],
|
||||
);
|
||||
|
||||
// Auto-expand Advanced on the missing→present-requirement transition only.
|
||||
// Wait for the baked-keys query to settle before firing: on first dialog open
|
||||
// the query is still in-flight so bakedEnvKeys is undefined, which transiently
|
||||
// marks baked-covered keys as missing. An errored query still counts as settled
|
||||
// (fail-closed for badge/save purposes, but no premature expand).
|
||||
const previousMissing = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
previousMissing.current = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
requiredEnvKeyMissing &&
|
||||
!previousMissing.current &&
|
||||
bakedEnvKeysFetched
|
||||
) {
|
||||
setShowAdvancedFields(true);
|
||||
}
|
||||
previousMissing.current = requiredEnvKeyMissing;
|
||||
}, [open, requiredEnvKeyMissing, bakedEnvKeysFetched, setShowAdvancedFields]);
|
||||
|
||||
return { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing };
|
||||
return {
|
||||
requiredEnvKeys,
|
||||
fileSatisfiedEnvKeys,
|
||||
requiredEnvKeyMissing,
|
||||
settled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,6 +241,12 @@ type E2eConfig = {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
};
|
||||
/** Baked build env returned by the display and key-name Tauri commands. */
|
||||
bakedBuildEnv?: Array<{
|
||||
key: string;
|
||||
masked: boolean;
|
||||
value: string;
|
||||
}>;
|
||||
/** Delay (ms) applied to `set_global_agent_config` so tests can observe
|
||||
* autosave behaviour while a request is in flight. 0/undefined = instant.
|
||||
* Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */
|
||||
@@ -9315,11 +9321,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
};
|
||||
}
|
||||
case "get_baked_build_env":
|
||||
// Mock always returns an empty baked env (OSS build simulation).
|
||||
return [];
|
||||
return config?.mock?.bakedBuildEnv ?? [];
|
||||
case "get_baked_build_env_keys":
|
||||
// Mock always returns no baked env key names (OSS build simulation).
|
||||
return [];
|
||||
return (config?.mock?.bakedBuildEnv ?? []).map((entry) => entry.key);
|
||||
case "update_managed_agent":
|
||||
return handleUpdateManagedAgent(
|
||||
payload as Parameters<typeof handleUpdateManagedAgent>[0],
|
||||
|
||||
@@ -160,61 +160,57 @@ test.describe("agent readiness gate screenshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Shot 03: buzz-agent + anthropic + model set, ANTHROPIC_API_KEY missing →
|
||||
// amber required row names the key, submit ENABLED (no longer blocked).
|
||||
test("03-create-missing-credential-row", async ({ page }) => {
|
||||
test("03-create-missing-credential-shows-top-level-required-field", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await openCreateDialog(page);
|
||||
await selectProvider(page, "Anthropic");
|
||||
await setCustomModel(page, "claude-opus-4-5");
|
||||
|
||||
// The amber required rows render in the env-vars editor, which lives in
|
||||
// the Advanced section of the unified dialog.
|
||||
await page.getByRole("button", { name: "Advanced", exact: true }).click();
|
||||
|
||||
// Required row should name ANTHROPIC_API_KEY; submit is now ENABLED.
|
||||
await expect(page.getByTestId("env-vars-required-key")).toHaveText(
|
||||
"ANTHROPIC_API_KEY",
|
||||
);
|
||||
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
|
||||
await expect(page.getByLabel("Anthropic API Key")).toBeVisible();
|
||||
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Advanced", exact: true }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// Scroll the required row into view so it is visible in the screenshot.
|
||||
// Use evaluate to avoid detachment races with the motion.div container.
|
||||
const createCountBefore = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
|
||||
).__BUZZ_E2E_COMMANDS__?.filter(
|
||||
(command) => command === "create_persona",
|
||||
).length ?? 0,
|
||||
);
|
||||
await page
|
||||
.getByTestId("env-vars-required-key")
|
||||
.evaluate((el) => el.scrollIntoView({ block: "nearest" }));
|
||||
await settleAnimations(page);
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.screenshot({
|
||||
path: `${SHOTS}/03-create-missing-credential-row.png`,
|
||||
});
|
||||
.locator("#persona-dialog-form")
|
||||
.evaluate((form) => (form as HTMLFormElement).requestSubmit());
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
|
||||
).__BUZZ_E2E_COMMANDS__?.filter(
|
||||
(command) => command === "create_persona",
|
||||
).length ?? 0,
|
||||
),
|
||||
)
|
||||
.toBe(createCountBefore);
|
||||
});
|
||||
|
||||
// Shot 04: all required fields satisfied → Create button enabled.
|
||||
test("04-create-all-required-satisfied-enabled", async ({ page }) => {
|
||||
test("04-create-top-level-api-key-enables-submit", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await openCreateDialog(page);
|
||||
await selectProvider(page, "Anthropic");
|
||||
await setCustomModel(page, "claude-opus-4-5");
|
||||
// Fill the required ANTHROPIC_API_KEY amber row in the EnvVarsEditor.
|
||||
// Advanced auto-expands when required keys are missing.
|
||||
await page
|
||||
.getByLabel("Value for ANTHROPIC_API_KEY")
|
||||
.fill("sk-test-api-key-for-e2e");
|
||||
await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e");
|
||||
|
||||
// All required fields satisfied → submit enabled.
|
||||
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(page);
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.screenshot({
|
||||
path: `${SHOTS}/04-create-all-required-satisfied-enabled.png`,
|
||||
});
|
||||
});
|
||||
|
||||
// Shot 05: claude runtime (CLI-login) — provider/model not required, submit enabled.
|
||||
|
||||
@@ -2,6 +2,17 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const BAKED_DEFAULTS = [
|
||||
{ key: "BUZZ_AGENT_PROVIDER", value: "anthropic", masked: false },
|
||||
{
|
||||
key: "BUZZ_AGENT_MODEL",
|
||||
value: "claude-opus-4-8",
|
||||
masked: false,
|
||||
},
|
||||
{ key: "BUZZ_AGENT_THINKING_EFFORT", value: "high", masked: false },
|
||||
{ key: "ANTHROPIC_API_KEY", value: "sk-ant-baked-test", masked: true },
|
||||
];
|
||||
|
||||
// Edit-agent dialog coverage (Phase 1B.3b-pre). Written against TODAY'S
|
||||
// EditAgentDialog, before the B3b re-host, so the re-host is guarded by a
|
||||
// pre-existing spec rather than one written alongside it.
|
||||
@@ -123,9 +134,7 @@ test.describe("edit agent dialog", () => {
|
||||
await pickDropdownOption(page, "edit-agent-model", "Custom model...");
|
||||
await page.locator("#edit-agent-custom-model").fill("claude-opus-4-5");
|
||||
// Anthropic requires a credential before save unlocks.
|
||||
await page
|
||||
.getByTestId("env-vars-required-value")
|
||||
.fill("sk-test-edit-agent-e2e");
|
||||
await page.getByLabel("Anthropic API Key").fill("sk-test-edit-agent-e2e");
|
||||
|
||||
const submit = page.getByTestId("edit-agent-dialog-submit");
|
||||
await expect(submit).toBeEnabled({ timeout: 10_000 });
|
||||
@@ -144,6 +153,65 @@ test.describe("edit agent dialog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("shows baked defaults in the instance editor", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
bakedBuildEnv: BAKED_DEFAULTS,
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_PUBKEY,
|
||||
name: AGENT_NAME,
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openEditDialog(page);
|
||||
|
||||
await expect(page.locator("#edit-agent-llm-provider")).toHaveText(
|
||||
"Anthropic (inherited from build)",
|
||||
);
|
||||
await expect(page.locator("#edit-agent-model")).toHaveText(
|
||||
"Inherit build default (claude-opus-4-8)",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Using build defaults: effort high"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("explicit global defaults override baked labels in the instance editor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
bakedBuildEnv: BAKED_DEFAULTS,
|
||||
globalAgentConfig: {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "low" },
|
||||
},
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_PUBKEY,
|
||||
name: AGENT_NAME,
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openEditDialog(page);
|
||||
|
||||
await expect(page.locator("#edit-agent-llm-provider")).toHaveText(
|
||||
"Inherit global default (anthropic)",
|
||||
);
|
||||
await expect(page.locator("#edit-agent-model")).toHaveText(
|
||||
"Inherit global default (claude-opus-4-5)",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Using global defaults: effort low"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("profile Edit routes persona-linked agents to the definition editor", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -76,10 +76,7 @@ test.describe("global agent config screenshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Shot 02: Create Agent with global provider = anthropic, no per-agent
|
||||
// provider selected, Advanced section auto-expanded, ANTHROPIC_API_KEY
|
||||
// shown as a required amber row (Test 2.1 + 2.2 fix).
|
||||
test("02-create-global-provider-required-key-advanced-open", async ({
|
||||
test("02-create-global-provider-shows-top-level-api-key", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
@@ -92,30 +89,15 @@ test.describe("global agent config screenshots", () => {
|
||||
|
||||
await openCreateDialog(page);
|
||||
|
||||
// With global provider = anthropic and no per-agent provider set, the gate
|
||||
// derives the effective provider as anthropic → ANTHROPIC_API_KEY required.
|
||||
// The Advanced section auto-expands when required env keys appear.
|
||||
await expect(page.getByTestId("env-vars-required-key")).toHaveText(
|
||||
"ANTHROPIC_API_KEY",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
// Scroll the required row into view.
|
||||
// Use evaluate to avoid detachment races with the motion.div container.
|
||||
await page
|
||||
.getByTestId("env-vars-required-key")
|
||||
.evaluate((el) => el.scrollIntoView({ block: "nearest" }));
|
||||
await settleAnimations(page);
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.screenshot({
|
||||
path: `${SHOTS}/02-create-global-provider-required-key-advanced-open.png`,
|
||||
await expect(page.getByLabel("Anthropic API Key")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Advanced", exact: true }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.getByTestId("env-vars-required-key")).not.toBeVisible();
|
||||
});
|
||||
|
||||
// Shot 03: Global env satisfies ANTHROPIC_API_KEY — no required amber row,
|
||||
// Advanced stays collapsed, and the Create button is enabled (Test 4 nuance fix:
|
||||
// globally-satisfied keys are excluded from requiredKeys entirely).
|
||||
test("03-global-env-satisfies-required-key", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
globalAgentConfig: {
|
||||
@@ -127,30 +109,92 @@ test.describe("global agent config screenshots", () => {
|
||||
|
||||
await openCreateDialog(page);
|
||||
|
||||
// Global env_vars satisfies ANTHROPIC_API_KEY, so computeLocalModeGate
|
||||
// excludes it from requiredEnvKeys — no locked amber row rendered.
|
||||
await expect(page.locator("#persona-llm-provider")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// No required rows present — globally satisfied keys have no amber row.
|
||||
await expect(page.getByLabel("Anthropic API Key")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Inherited from global config",
|
||||
);
|
||||
await expect(page.getByTestId("env-vars-required-key")).not.toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Submit is enabled: effectiveProvider = global "anthropic" is valid.
|
||||
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// Scroll down to show the env section — empty of amber required rows because
|
||||
// the globally-satisfied key is excluded from requiredEnvKeys entirely.
|
||||
const dialog = page.getByRole("dialog");
|
||||
const envEditor = dialog.getByTestId("env-vars-editor");
|
||||
await envEditor.evaluate((el) => el.scrollIntoView({ block: "nearest" }));
|
||||
await settleAnimations(page);
|
||||
|
||||
await dialog.screenshot({
|
||||
path: `${SHOTS}/03-global-env-satisfies-required-key.png`,
|
||||
test("06-baked-defaults-labels-appear-in-create-dialog", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
bakedBuildEnv: [
|
||||
{
|
||||
key: "BUZZ_AGENT_PROVIDER",
|
||||
value: "anthropic",
|
||||
masked: false,
|
||||
},
|
||||
{
|
||||
key: "BUZZ_AGENT_MODEL",
|
||||
value: "claude-opus-4-8",
|
||||
masked: false,
|
||||
},
|
||||
{
|
||||
key: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
value: "high",
|
||||
masked: false,
|
||||
},
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-baked-test",
|
||||
masked: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openCreateDialog(page);
|
||||
|
||||
await expect(page.locator("#persona-llm-provider")).toHaveText(
|
||||
"Anthropic (inherited from build)",
|
||||
);
|
||||
await expect(page.locator("#persona-model")).toHaveText(
|
||||
"Inherit build default (claude-opus-4-8)",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Using build defaults: effort high"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("07-explicit-global-defaults-override-baked-labels", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
globalAgentConfig: {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "low" },
|
||||
},
|
||||
bakedBuildEnv: [
|
||||
{
|
||||
key: "BUZZ_AGENT_PROVIDER",
|
||||
value: "databricks_v2",
|
||||
masked: false,
|
||||
},
|
||||
{ key: "BUZZ_AGENT_MODEL", value: "build-model", masked: false },
|
||||
{
|
||||
key: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
value: "high",
|
||||
masked: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openCreateDialog(page);
|
||||
|
||||
await expect(page.locator("#persona-llm-provider")).toHaveText(
|
||||
"Inherit global default (anthropic)",
|
||||
);
|
||||
await expect(page.locator("#persona-model")).toHaveText(
|
||||
"Inherit global default (claude-opus-4-5)",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Using global defaults: effort low"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Shot 04: Create gate BLOCKED — no per-agent provider, no global provider
|
||||
|
||||
@@ -277,9 +277,7 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({
|
||||
// by this branch), so page-wide testid queries would match both.
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
// The default runtime is buzz-agent — Advanced auto-expands on open so the
|
||||
// model-tuning knobs are immediately reachable. The env-vars editor is
|
||||
// already visible; no click needed to expand.
|
||||
await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
|
||||
await expect(dialog.getByTestId("env-vars-editor")).toBeVisible();
|
||||
// Initially empty (no rows — buzz-agent with no provider has no required keys).
|
||||
await expect(dialog.getByTestId("env-vars-key")).toHaveCount(0);
|
||||
@@ -328,20 +326,12 @@ test("persona model options follow the selected LLM provider", async ({
|
||||
// Without live discovery, the only static option is "Default model".
|
||||
await expect(model).toContainText("Default model");
|
||||
|
||||
// Switch to OpenAI — OPENAI_COMPAT_API_KEY appears as an amber required row
|
||||
// in the EnvVarsEditor (Advanced auto-expands when a required key is missing).
|
||||
await selectDropdownOption(page, llmProvider, "OpenAI");
|
||||
const dialog = page.getByRole("dialog");
|
||||
// Advanced is now expanded and the editor is visible.
|
||||
await expect(dialog.getByTestId("env-vars-editor")).toBeVisible();
|
||||
// The required amber row for OPENAI_COMPAT_API_KEY is present.
|
||||
const openAiRequiredRow = dialog.locator(
|
||||
'[data-testid="env-vars-required-key"]',
|
||||
{
|
||||
hasText: "OPENAI_COMPAT_API_KEY",
|
||||
},
|
||||
);
|
||||
await expect(openAiRequiredRow).toBeVisible();
|
||||
await expect(dialog.getByLabel("OpenAI API Key")).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Advanced", exact: true }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(model).toBeVisible();
|
||||
// OpenAI requires an explicit model, so "Default model" is filtered out.
|
||||
// The combobox offers only "Custom model..." — verify it is present and selectable.
|
||||
@@ -350,15 +340,9 @@ test("persona model options follow the selected LLM provider", async ({
|
||||
.getByRole("button", { name: "Custom model...", exact: true })
|
||||
.click();
|
||||
|
||||
// Switch to Anthropic — ANTHROPIC_API_KEY amber row appears; OPENAI_COMPAT_API_KEY gone.
|
||||
await selectDropdownOption(page, llmProvider, "Anthropic");
|
||||
const anthropicRequiredRow = dialog.locator(
|
||||
'[data-testid="env-vars-required-key"]',
|
||||
{
|
||||
hasText: "ANTHROPIC_API_KEY",
|
||||
},
|
||||
);
|
||||
await expect(anthropicRequiredRow).toBeVisible();
|
||||
await expect(dialog.getByLabel("Anthropic API Key")).toBeVisible();
|
||||
await expect(dialog.getByLabel("OpenAI API Key")).not.toBeVisible();
|
||||
await expect(model).toBeVisible();
|
||||
|
||||
// Switch to Default (no explicit provider) — required rows disappear,
|
||||
|
||||
@@ -140,23 +140,13 @@ test("create agent supports parallelism and system prompt overrides", async ({
|
||||
.getByRole("button", { name: "Custom model...", exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Custom model ID").fill("claude-opus-4-5");
|
||||
// Supply a credential so the agent can actually run (create is no longer
|
||||
// blocked by a missing key, but we include it for a realistic test).
|
||||
// The required ANTHROPIC_API_KEY amber row appears in the EnvVarsEditor
|
||||
// (Advanced auto-expands when required keys are present).
|
||||
await page
|
||||
.getByLabel("Value for ANTHROPIC_API_KEY")
|
||||
.fill("sk-test-api-key-for-e2e");
|
||||
await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e");
|
||||
|
||||
// Fix 3 (auto-open Advanced when required keys appear) may have already
|
||||
// opened the section; only click to open if it is currently collapsed.
|
||||
const advancedToggle = page.getByRole("button", {
|
||||
name: "Advanced",
|
||||
exact: true,
|
||||
});
|
||||
if ((await advancedToggle.getAttribute("aria-expanded")) === "false") {
|
||||
await advancedToggle.click();
|
||||
}
|
||||
await advancedToggle.click();
|
||||
// Parallelism is above the env-vars editor in the Advanced section; filling
|
||||
// the required API-key row may have scrolled the dialog past it. Scroll back.
|
||||
await page
|
||||
|
||||
@@ -295,6 +295,11 @@ type MockBridgeOptions = {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
};
|
||||
bakedBuildEnv?: Array<{
|
||||
key: string;
|
||||
masked: boolean;
|
||||
value: string;
|
||||
}>;
|
||||
/** Delay (ms) for `set_global_agent_config` — hold saves open in tests.
|
||||
* Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */
|
||||
setGlobalAgentConfigDelayMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user