feat(desktop): add API key field, global default indicators, and collapsed advanced (#1875)

This commit is contained in:
Will Pfleger
2026-07-14 20:10:39 -04:00
committed by GitHub
parent a26168f553
commit 1742dce7b2
26 changed files with 1282 additions and 421 deletions

View File

@@ -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);

View File

@@ -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.

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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),

View File

@@ -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) };

View File

@@ -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}

View File

@@ -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>
);
}

View File

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

View File

@@ -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", () => {

View File

@@ -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;

View File

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

View File

@@ -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

View File

@@ -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])
);
}

View File

@@ -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);
});

View 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;
}

View 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,
};
}

View File

@@ -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,
};
}

View File

@@ -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],

View File

@@ -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.

View File

@@ -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,
}) => {

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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;