Clarify agent harness defaults in create flow (#2601)

This commit is contained in:
klopez4212
2026-07-24 01:23:31 +01:00
committed by GitHub
parent 3d17195ac8
commit 76aeae7036
33 changed files with 1489 additions and 855 deletions
+21
View File
@@ -79,6 +79,27 @@ with a TypeScript lookup table or an id comparison in a component.
harnesses always keep the field. Gate: `defaults hides model when optional
harness has empty discovery` (and the failed-discovery counterpart) in
`onboarding-agent-defaults.spec.ts`.
9. **The defaults modal is progressively disclosed.** An unset global config
starts on the Buzz Agent-first deployment fallback and carries that visible
harness into the next saved edit. The `progressive-defaults` disclosure
preset therefore begins at Provider for Buzz Agent, then reveals Model,
Effort, and Advanced only after a provider is configured. Harnesses whose
runtime metadata has no provider field skip that gate. Reveals animate their
height through Motion and become immediate when reduced motion is requested.
Once the Advanced toggle is visible, its expanded state is exclusively
user-controlled: provider, harness, and required-env changes must never
open it automatically in defaults, create, or edit flows. In Create mode,
the defaults summary follows preferred-harness changes saved while the
dialog is open, and its configured state includes required credentials as
well as provider/model values. If no available harness can resolve, Create
starts in Customize and lets unavailable catalog entries be selected only
to expose their setup guidance; submission remains blocked.
Advanced-only required credentials mark the collapsed Advanced toggle
without opening it in Global Defaults and Edit, and block incomplete saves.
Runtime-file credentials satisfy Global Defaults just as they do Create and
Edit. In Edit,
selecting Custom command keeps its required command field beside the harness
picker rather than hiding it in Advanced.
## The tests that enforce this
@@ -0,0 +1,26 @@
import { hasMissingRequiredEnvKey } from "./personaRuntimeModel";
export function AdvancedRequiredBadge({
envVars,
requiredEnvKeys,
show,
testId,
}: {
envVars?: Record<string, string>;
requiredEnvKeys?: readonly string[];
show?: boolean;
testId: string;
}) {
const visible =
show ?? hasMissingRequiredEnvKey(requiredEnvKeys ?? [], envVars ?? {});
if (!visible) return null;
return (
<span
aria-hidden="true"
className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive"
data-testid={testId}
>
Required
</span>
);
}
@@ -1,20 +1,67 @@
import type * as React from "react";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import type { InheritedDefault } from "./bakedEnvHelpers";
export type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy";
export function HarnessModelDefaultNotice({
harness,
model,
}: {
harness: string;
model?: string | null;
}) {
return (
<div className="text-sm" data-testid="agent-harness-defaults-notice">
<span className="text-muted-foreground">Model</span>{" "}
<span className="text-foreground">
<dl
className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 text-sm"
data-testid="agent-harness-defaults-notice"
>
<dt className="text-muted-foreground">Harness</dt>
<dd className="truncate text-foreground">
{harness || "Not configured"}
</dd>
<dt className="text-muted-foreground">Model</dt>
<dd className="truncate text-foreground">
{model?.trim() || "Harness default"}
</span>
</div>
</dd>
</dl>
);
}
export function AgentCreateAiDefaultsSummary({
canChooseProvider,
harness,
inheritedModel,
inheritedProvider,
isConfigured,
model,
onEditDefaults,
triggerRef,
}: {
canChooseProvider: boolean;
harness: string;
inheritedModel: InheritedDefault;
inheritedProvider: InheritedDefault;
isConfigured: boolean;
model?: string | null;
onEditDefaults: () => void;
triggerRef?: React.Ref<HTMLButtonElement>;
}) {
return canChooseProvider ? (
<AgentAiDefaultsNotice
isConfigured={isConfigured}
onEditDefaults={onEditDefaults}
triggerRef={triggerRef}
explicitModel=""
explicitProvider=""
harness={harness}
inheritedModel={inheritedModel}
inheritedProvider={inheritedProvider}
/>
) : (
<HarnessModelDefaultNotice harness={harness} model={model} />
);
}
@@ -36,22 +83,31 @@ export function AgentAiConfigurationModeField({
}
value={mode}
>
<TabsList>
<TabsTrigger value="defaults">
<TabsList className="relative isolate grid h-9 w-full grid-cols-2 overflow-hidden rounded-lg bg-muted p-0.5">
<div
aria-hidden="true"
className="absolute bottom-0.5 left-0.5 top-0.5 z-0 rounded-md bg-background shadow-sm transition-transform duration-[250ms] ease-out"
style={{
transform: `translateX(${mode === "custom" ? 100 : 0}%)`,
width: "calc((100% - 4px) / 2)",
}}
/>
<TabsTrigger
className="relative z-10 h-full rounded-md bg-transparent text-xs font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
value="defaults"
>
{needsProviderSelection
? "Use agent defaults"
: "Use harness defaults"}
</TabsTrigger>
<TabsTrigger value="custom">Customize for this agent</TabsTrigger>
<TabsTrigger
className="relative z-10 h-full rounded-md bg-transparent text-xs font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
value="custom"
>
Customize for this agent
</TabsTrigger>
</TabsList>
</Tabs>
{mode === "custom" ? (
<p className="text-xs text-muted-foreground">
{needsProviderSelection
? "Provider and model changes apply only to this agent."
: "Model changes apply only to this agent."}
</p>
) : null}
</div>
);
}
@@ -26,26 +26,62 @@ export function formatAiDefaultsSummary({
}
export function AgentAiDefaultsNotice({
isConfigured = true,
onEditDefaults,
triggerRef,
explicitModel,
explicitProvider,
harness,
inheritedModel,
inheritedProvider,
}: {
isConfigured?: boolean;
onEditDefaults: () => void;
triggerRef?: React.Ref<HTMLButtonElement>;
explicitModel: string;
explicitProvider: string;
harness?: string;
inheritedModel: InheritedDefault;
inheritedProvider: InheritedDefault;
}) {
const provider = explicitProvider.trim() || inheritedProvider.value;
const model = explicitModel.trim() || inheritedModel.value;
if (!isConfigured) {
return (
<div
className="flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-3 py-2.5"
data-testid="agent-ai-defaults-notice"
>
<p className="text-sm font-medium text-foreground">
Global defaults not set
</p>
<Button
className="shrink-0"
data-testid="set-ai-defaults"
onClick={onEditDefaults}
ref={triggerRef}
size="sm"
type="button"
variant="outline"
>
Set
</Button>
</div>
);
}
return (
<div className="space-y-1" data-testid="agent-ai-defaults-notice">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 text-sm">
{harness !== undefined ? (
<>
<dt className="text-muted-foreground">Harness</dt>
<dd className="truncate text-foreground">
{harness || "Not configured"}
</dd>
</>
) : null}
<dt className="text-muted-foreground">Provider</dt>
<dd className="truncate text-foreground">
{provider ? providerLabel(provider) : "Not configured"}
@@ -7,8 +7,12 @@
*/
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { BakedEnvEntry } from "@/shared/api/tauri";
import type {
BakedEnvEntry,
RuntimeFileConfigSubset,
} from "@/shared/api/tauri";
import type {
AcpRuntimeCatalogEntry,
GlobalAgentConfig,
@@ -31,10 +35,10 @@ import {
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getPersonaProviderOptions,
getProviderApiKeyEnvVar,
requiredCredentialEnvKeys,
runtimeSupportsLlmProviderSelection,
} from "@/features/agents/ui/agentConfigOptions";
import {
AgentConfigTextInput,
AgentDropdownSelect,
AgentModelField,
} from "@/features/agents/ui/agentConfigControls";
@@ -48,10 +52,10 @@ import {
EffortSelectField,
useEffortAutoClear,
} from "@/features/agents/ui/buzzAgentModelTuningFields";
import { Input } from "@/shared/ui/input";
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
import { getGlobalAgentCredentialState } from "./globalAgentCredentialState";
/** Sentinel value for an unconfigured global agent config. */
export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
env_vars: {},
provider: null,
@@ -66,6 +70,16 @@ const BAKED_STRUCTURED_KEYS = new Set([
BUZZ_AGENT_THINKING_EFFORT,
]);
const PROGRESSIVE_FIELDS_TRANSITION = {
duration: 0.22,
ease: [0.23, 1, 0.32, 1],
} as const;
type AgentConfigDisclosure =
| "full"
| "onboarding-essential"
| "progressive-defaults";
// Canonical behaviors (PR 2 flag cleanup). These were per-surface props;
// onboarding's values won every call and are now the only behavior:
// - auto-select a valid model when the provider changes
@@ -92,12 +106,13 @@ export const CANONICAL_CONFIG_BEHAVIORS = {
} as const;
/**
* Disclosure preset → the eight visibility decisions it owns. Effort is
* shown in both presets (onboarding never hid it; the old prop existed but
* was never flipped). Exported for the contract test.
* Disclosure preset → the eight visibility decisions it owns. Full and
* progressive defaults expose the same controls; the progressive preset
* changes only when those controls are revealed. Exported for the contract
* test.
*/
export function resolveDisclosure(disclosure: "full" | "onboarding-essential") {
const full = disclosure === "full";
export function resolveDisclosure(disclosure: AgentConfigDisclosure) {
const full = disclosure !== "onboarding-essential";
return {
showAdvancedFields: full,
showCustomModelOption: full,
@@ -110,6 +125,22 @@ export function resolveDisclosure(disclosure: "full" | "onboarding-essential") {
} as const;
}
export function shouldRevealDependentConfigFields({
disclosure,
providerFieldVisible,
providerValue,
}: {
disclosure: AgentConfigDisclosure;
providerFieldVisible: boolean;
providerValue: string;
}): boolean {
return (
disclosure !== "progressive-defaults" ||
!providerFieldVisible ||
providerValue.trim().length > 0
);
}
/**
* Determines whether the status line beneath the Model field should render.
*
@@ -170,6 +201,7 @@ export type AgentConfigFieldsProps = {
onCustomModelEditingChange: (value: boolean) => void;
onIsCustomProviderChange: (value: boolean) => void;
onValidityChange?: (valid: boolean) => void;
runtimeFileConfig?: RuntimeFileConfigSubset | null;
placeholderClassName?: string;
selectClassName?: string;
/**
@@ -182,11 +214,14 @@ export type AgentConfigFieldsProps = {
* valid forward choices. No advanced section, no custom escape hatches,
* no descriptions (the page copy does that job), no un-choosing via
* placeholder options, no greyed-out effort levels.
* - "progressive-defaults": the defaults modal's full controls, revealed in
* order. Provider appears after harness selection; model, effort, and
* Advanced appear after a provider is configured.
* If a second surface wants the trimmed view, rename this value to plain
* "essential" — and have the conversation about whether it should really
* match onboarding.
*/
disclosure?: "full" | "onboarding-essential";
disclosure?: AgentConfigDisclosure;
unstyled?: boolean;
useCustomSelect?: boolean;
useChevronSelectIcon?: boolean;
@@ -202,6 +237,7 @@ export function AgentConfigFields({
onCustomModelEditingChange,
onIsCustomProviderChange,
onValidityChange,
runtimeFileConfig,
placeholderClassName,
selectClassName,
disclosure = "full",
@@ -209,6 +245,7 @@ export function AgentConfigFields({
useCustomSelect = false,
useChevronSelectIcon = false,
}: AgentConfigFieldsProps) {
const shouldReduceMotion = useReducedMotion();
const {
showAdvancedFields,
showCustomModelOption,
@@ -260,9 +297,6 @@ export function AgentConfigFields({
modelIsOptional ||
(config.model?.trim().length ?? 0) > 0 ||
fallbackModel !== null;
React.useEffect(() => {
onValidityChange?.(modelIsValid);
}, [modelIsValid, onValidityChange]);
const bakedEffort = React.useMemo(
() =>
bakedEnv.find((e) => e.key === BUZZ_AGENT_THINKING_EFFORT)?.value ?? null,
@@ -278,10 +312,18 @@ export function AgentConfigFields({
providerFieldVisible && !isCustomProvider
? providerValue || bakedProvider || ""
: "";
const configuredProviderValue = isCustomProvider
? providerValue
: providerForDiscovery;
const dependentFieldsDisabled =
providerFieldVisible &&
requireProviderForModelAndEffort &&
providerForDiscovery.trim().length === 0;
configuredProviderValue.trim().length === 0;
const revealDependentFields = shouldRevealDependentConfigFields({
disclosure,
providerFieldVisible,
providerValue: configuredProviderValue,
});
const credentialProvider =
providerFieldVisible && !isCustomProvider ? effectiveProvider : "";
const credentialRuntimeId = runtimeSupportsLlmProviderSelection(
@@ -289,24 +331,31 @@ export function AgentConfigFields({
)
? selectedRuntimeId
: "buzz-agent";
const requiredEnvKeys = requiredCredentialEnvKeys(
credentialRuntimeId,
credentialProvider,
);
const apiKeyEnvVar = getProviderApiKeyEnvVar(credentialProvider);
const advancedRequiredEnvKeys = requiredEnvKeys.filter(
(key) =>
key !== apiKeyEnvVar && !bakedEnv.some((entry) => entry.key === key),
);
const apiKeyValue = apiKeyEnvVar ? (config.env_vars[apiKeyEnvVar] ?? "") : "";
const bakedEnvKeys = React.useMemo(
() => bakedEnv.map((entry) => entry.key),
[bakedEnv],
);
const apiKeyInherited =
apiKeyEnvVar !== null &&
apiKeyValue.length === 0 &&
bakedEnvKeys.includes(apiKeyEnvVar);
const {
advancedCredentialMissing,
advancedFileSatisfiedEnvKeys,
advancedRequiredEnvKeys,
apiKeyEnvVar,
apiKeyFileSatisfied,
apiKeyInherited,
apiKeyValue,
credentialsValid,
} = getGlobalAgentCredentialState({
bakedEnvKeys,
envVars: config.env_vars,
provider: credentialProvider,
runtimeFileConfig,
runtimeId: credentialRuntimeId,
});
const configIsValid =
selectedRuntimeId.length > 0 && modelIsValid && credentialsValid;
React.useEffect(() => {
onValidityChange?.(configIsValid);
}, [configIsValid, onValidityChange]);
const {
discoveredModelOptions,
@@ -343,16 +392,9 @@ export function AgentConfigFields({
const healOnMount =
fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup";
const userEditedProviderRef = React.useRef(false);
// Env vars live under a collapsed Advanced section (matching the create
// flow). Auto-open when a required key is missing so the field the user
// must fill is never hidden behind the toggle.
// Advanced visibility is user-controlled. Provider changes can add required
// rows, but must not open this section without an explicit toggle click.
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const requiredAdvancedKeyMissing = advancedRequiredEnvKeys.some(
(key) => !(config.env_vars[key] ?? "").trim(),
);
React.useEffect(() => {
if (requiredAdvancedKeyMissing) setAdvancedOpen(true);
}, [requiredAdvancedKeyMissing]);
// Read inside effects via ref so biome's exhaustive-deps stays honest:
// refs are stable, and healOnMount is captured at declaration.
const mayMutateDependentFieldsRef = React.useRef(false);
@@ -598,9 +640,15 @@ export function AgentConfigFields({
: "";
const effortFieldVisible = showEffortField && effortField !== undefined;
const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3";
const progressiveDefaults = disclosure === "progressive-defaults";
const fieldClassName = unstyled
? progressiveDefaults
? "space-y-1.5"
: "space-y-4"
: "space-y-1.5 p-3";
const blockClassName = unstyled ? "" : "p-3";
const fieldLabelClassName = unstyled ? "pl-3" : undefined;
const fieldLabelClassName =
unstyled && !progressiveDefaults ? "pl-3" : undefined;
const providerDropdownOptions = [
...providerOptions
.filter(
@@ -661,44 +709,49 @@ export function AgentConfigFields({
</select>
);
const content = (
<>
{providerFieldVisible ? (
<div className={fieldClassName}>
<label
className={cn("text-sm font-medium", fieldLabelClassName)}
htmlFor="global-agent-provider"
>
Provider
</label>
{!useCustomSelect && useChevronSelectIcon ? (
<div className="relative">
{providerSelect}
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground"
/>
</div>
) : (
providerSelect
)}
{isCustomProvider ? (
<Input
aria-label="Custom global provider ID"
autoCorrect="off"
onChange={(e) => handleCustomProviderInput(e.target.value)}
placeholder="Custom provider ID"
value={providerValue}
/>
) : null}
const providerContent = providerFieldVisible ? (
<div className={fieldClassName}>
<label
className={cn("text-sm font-medium", fieldLabelClassName)}
htmlFor="global-agent-provider"
>
Provider
</label>
{!useCustomSelect && useChevronSelectIcon ? (
<div className="relative">
{providerSelect}
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground"
/>
</div>
) : (
providerSelect
)}
{isCustomProvider ? (
<AgentConfigTextInput
aria-label="Custom global provider ID"
autoCorrect="off"
onChange={(e) => handleCustomProviderInput(e.target.value)}
placeholder="Custom provider ID"
usePersonaInputStyle={progressiveDefaults}
value={providerValue}
/>
) : null}
</div>
) : null;
const dependentContent = (
<>
{providerFieldVisible && apiKeyEnvVar ? (
<div className={blockClassName}>
<PersonaProviderApiKeyField
disabled={false}
inheritedLabel="Provided by this build"
inheritedLabel={
apiKeyFileSatisfied
? "Set in runtime config"
: "Provided by this build"
}
isInherited={apiKeyInherited}
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
label={
@@ -763,6 +816,7 @@ export function AgentConfigFields({
testId="global-agent-model"
useCustomSelect={useCustomSelect}
useChevronIcon={useChevronSelectIcon}
usePersonaInputStyle={progressiveDefaults}
/>
</div>
) : null}
@@ -817,12 +871,19 @@ export function AgentConfigFields({
<div className={cn(blockClassName, "space-y-3")}>
<button
aria-expanded={advancedOpen}
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className={cn(
"inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
unstyled && "ml-3",
)}
data-testid="global-agent-advanced-toggle"
onClick={() => setAdvancedOpen((current) => !current)}
type="button"
>
<span>Advanced</span>
<AdvancedRequiredBadge
show={advancedCredentialMissing}
testId="global-agent-advanced-required-badge"
/>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
@@ -830,8 +891,42 @@ export function AgentConfigFields({
)}
/>
</button>
{advancedOpen ? (
{disclosure === "progressive-defaults" ? (
<AnimatePresence initial={false}>
{advancedOpen ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
data-testid="global-agent-advanced-fields-motion"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key="global-agent-advanced-fields"
transition={
shouldReduceMotion
? { duration: 0 }
: PROGRESSIVE_FIELDS_TRANSITION
}
>
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
value={Object.fromEntries(
Object.entries(config.env_vars).filter(
([k]) => k !== BUZZ_AGENT_THINKING_EFFORT,
),
)}
/>
</motion.div>
) : null}
</AnimatePresence>
) : advancedOpen ? (
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
@@ -850,9 +945,52 @@ export function AgentConfigFields({
</>
);
const content = (
<>
{providerContent}
{disclosure === "progressive-defaults" ? (
<AnimatePresence initial={false}>
{revealDependentFields ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className={cn(
"overflow-hidden",
unstyled && (progressiveDefaults ? "space-y-5" : "space-y-7"),
)}
data-testid="global-agent-dependent-fields-motion"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key="global-agent-dependent-fields"
transition={
shouldReduceMotion
? { duration: 0 }
: PROGRESSIVE_FIELDS_TRANSITION
}
>
{dependentContent}
</motion.div>
) : null}
</AnimatePresence>
) : (
dependentContent
)}
</>
);
if (unstyled) {
return <div className="space-y-7">{content}</div>;
return (
<div
className={progressiveDefaults ? "space-y-5" : "space-y-7"}
data-testid="global-agent-config-fields"
>
{content}
</div>
);
}
return <SettingsOptionGroup>{content}</SettingsOptionGroup>;
return (
<SettingsOptionGroup data-testid="global-agent-config-fields">
{content}
</SettingsOptionGroup>
);
}
@@ -94,6 +94,7 @@ export function AgentDefaultsDialog({
</DialogDescription>
</DialogHeader>
<AgentDefaultsEditor
layout="flat"
onDirtyChange={setDirty}
onSaveSuccess={handleSaveSuccess}
onSavingChange={setSaving}
@@ -8,6 +8,7 @@
* Precedence: baked floor < GLOBAL (this card) < persona < per-agent.
*/
import { AlertCircle, Check, Loader } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
@@ -19,10 +20,15 @@ import {
import type { GlobalAgentConfig } from "@/shared/api/types";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig";
import { useAcpRuntimesQuery } from "@/features/agents/hooks";
import {
useAcpRuntimesQuery,
useRuntimeFileConfigQuery,
} from "@/features/agents/hooks";
import {
formatRuntimeOptionLabel,
getDefaultPersonaRuntime,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
resetConfigForHarnessChange,
sortPersonaRuntimes,
} from "@/features/agents/ui/agentConfigOptions";
@@ -31,15 +37,28 @@ import {
AgentConfigFields,
EMPTY_GLOBAL_CONFIG,
} from "@/features/agents/ui/AgentConfigFields";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
type SaveState = "idle" | "saving" | "saved" | "error";
const PROGRESSIVE_FIELDS_TRANSITION = {
duration: 0.22,
ease: [0.23, 1, 0.32, 1],
} as const;
const PERSONA_SELECT_TRIGGER_CLASS = cn(
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
"h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60",
);
export type GlobalAgentConfigSaveResult = Awaited<
ReturnType<typeof setGlobalAgentConfig>
>;
type AgentDefaultsEditorProps = {
layout?: "flat" | "grouped";
onDirtyChange?: (dirty: boolean) => void;
onSaveSuccess?: (result: GlobalAgentConfigSaveResult) => void;
onSavingChange?: (saving: boolean) => void;
@@ -47,11 +66,14 @@ type AgentDefaultsEditorProps = {
};
export function AgentDefaultsEditor({
layout = "grouped",
onDirtyChange,
onSaveSuccess,
onSavingChange,
secondaryAction,
}: AgentDefaultsEditorProps) {
const flatLayout = layout === "flat";
const shouldReduceMotion = useReducedMotion();
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const configRef = React.useRef(config);
@@ -117,17 +139,28 @@ export function AgentDefaultsEditor({
() => sortPersonaRuntimes(runtimesQuery.data ?? []),
[runtimesQuery.data],
);
// A missing/stale preference displays the same effective fallback the backend
// would use; it is persisted only after the user edits and saves this form.
// Keep persona ordering here so this shared editor matches agent dialogs.
const selectedRuntime = React.useMemo(
() =>
sortedRuntimes.find(
(runtime) => runtime.id === config.preferred_runtime,
) ??
// An unset preferred runtime uses the same Buzz Agent-first fallback as
// deployment. The rendered draft below carries that fallback forward so the
// next user edit persists the visible harness instead of saving null.
const selectedRuntime = React.useMemo(() => {
const configuredRuntime = sortedRuntimes.find(
(runtime) => runtime.id === config.preferred_runtime,
);
return (
configuredRuntime ??
getDefaultPersonaRuntime(sortedRuntimes) ??
sortedRuntimes[0],
[config.preferred_runtime, sortedRuntimes],
sortedRuntimes[0]
);
}, [config.preferred_runtime, sortedRuntimes]);
const renderedConfig = React.useMemo(
() =>
config.preferred_runtime || !selectedRuntime
? config
: { ...config, preferred_runtime: selectedRuntime.id },
[config, selectedRuntime],
);
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(
selectedRuntime?.id ?? "",
);
const harnessOptions = React.useMemo(
() =>
@@ -141,7 +174,7 @@ export function AgentDefaultsEditor({
const configSurfaceError =
loadError ||
runtimesQuery.isError ||
(!configSurfaceLoading && selectedRuntime === undefined);
(!configSurfaceLoading && sortedRuntimes.length === 0);
function handleConfigChange(next: GlobalAgentConfig) {
configRef.current = next;
@@ -153,6 +186,7 @@ export function AgentDefaultsEditor({
function handleHarnessChange(runtimeId: string) {
handleConfigChange(resetConfigForHarnessChange(config, runtimeId));
setConfigIsValid(false);
setIsCustomModelEditing(false);
setIsCustomProvider(false);
}
@@ -202,8 +236,32 @@ export function AgentDefaultsEditor({
}
}
const configFields = selectedRuntime ? (
<AgentConfigFields
bakedEnv={bakedEnv}
selectedRuntime={selectedRuntime}
config={renderedConfig}
disclosure={flatLayout ? "progressive-defaults" : "full"}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={handleConfigChange}
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
onValidityChange={setConfigIsValid}
placeholderClassName={flatLayout ? "text-muted-foreground/55" : undefined}
runtimeFileConfig={runtimeFileConfig}
key={selectedRuntime.id}
selectClassName={flatLayout ? PERSONA_SELECT_TRIGGER_CLASS : undefined}
unstyled={flatLayout}
useCustomSelect
/>
) : null;
const progressiveFieldsTransition = shouldReduceMotion
? { duration: 0 }
: PROGRESSIVE_FIELDS_TRANSITION;
return (
<div className="min-w-0 space-y-4">
<div className={cn("min-w-0", flatLayout ? "space-y-7" : "space-y-4")}>
{configSurfaceLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader className="size-4 animate-spin" />
@@ -224,26 +282,37 @@ export function AgentDefaultsEditor({
Default harness
</label>
<AgentDropdownSelect
className={flatLayout ? PERSONA_SELECT_TRIGGER_CLASS : undefined}
id="global-agent-default-harness"
onValueChange={handleHarnessChange}
options={harnessOptions}
placeholder="Select a harness"
placeholderClassName={
flatLayout ? "text-muted-foreground/55" : undefined
}
testId="global-agent-default-harness"
value={selectedRuntime?.id ?? ""}
/>
</div>
<AgentConfigFields
bakedEnv={bakedEnv}
selectedRuntime={selectedRuntime}
config={config}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={handleConfigChange}
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
onValidityChange={setConfigIsValid}
useCustomSelect
/>
{flatLayout ? (
<AnimatePresence initial={false}>
{configFields ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
data-testid="global-agent-runtime-fields-motion"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key={selectedRuntime?.id}
transition={progressiveFieldsTransition}
>
{configFields}
</motion.div>
) : null}
</AnimatePresence>
) : (
configFields
)}
</>
)}
@@ -269,7 +338,12 @@ export function AgentDefaultsEditor({
<div className="ml-auto flex items-center gap-3">
{secondaryAction}
<Button
disabled={!dirty || !configIsValid || saveState === "saving"}
disabled={
!dirty ||
!configIsValid ||
selectedRuntime === undefined ||
saveState === "saving"
}
onClick={() => void handleSave()}
size="sm"
>
@@ -31,7 +31,6 @@ import {
emptyPersonaBehaviorDraft,
personaBehaviorDraftValid,
} from "./personaBehaviorDraft";
import { personaSubmitBlock } from "./personaSubmitBlock";
import {
AUTO_MODEL_DROPDOWN_VALUE,
AUTO_PROVIDER_DROPDOWN_VALUE,
@@ -69,11 +68,11 @@ import {
} from "./usePersonaModelDiscovery";
import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { AgentHarnessField } from "./AgentHarnessField";
import {
AgentAiConfigurationModeField,
HarnessModelDefaultNotice,
AgentCreateAiDefaultsSummary,
type AgentAiConfigurationMode,
} from "./AgentAiConfigurationMode";
import {
@@ -170,6 +169,7 @@ export function AgentDefinitionDialog({
() => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime),
[globalConfig.preferred_runtime, runtimes],
);
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
const shouldReduceMotion = useReducedMotion();
const initialModelProviderEditableWithoutRuntime = Boolean(
initialValues &&
@@ -208,9 +208,7 @@ export function AgentDefinitionDialog({
setBehaviorDraft(nextBehaviorDraft);
setNamePoolText(nextNamePoolText);
setEnvVars(nextEnvVars);
// 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.
// Advanced always starts collapsed and only changes from its toggle.
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
isRuntimeAutoSeededRef.current = false;
@@ -241,6 +239,46 @@ export function AgentDefinitionDialog({
}
}, [defaultRuntime, initialValues, open, runtime, runtimesLoading]);
// Keep an inherited Create runtime synced with defaults saved in-place.
React.useEffect(() => {
if (
!open ||
!initialValues ||
"id" in initialValues ||
initialValues.runtime?.trim() ||
aiConfigurationMode !== "defaults" ||
runtimesLoading ||
defaultRuntime === null ||
(runtime.trim().length > 0 && !isRuntimeAutoSeededRef.current)
) {
return;
}
if (runtime !== defaultRuntime.id) setRuntime(defaultRuntime.id);
isRuntimeAutoSeededRef.current = true;
hasSeededForOpenRef.current = true;
}, [
aiConfigurationMode,
defaultRuntime,
initialValues,
open,
runtime,
runtimesLoading,
]);
// Keep setup guidance reachable when no available runtime can be inherited.
React.useEffect(() => {
if (
open &&
isCreateMode &&
!runtimesLoading &&
defaultRuntime === null &&
runtime.trim().length === 0
) {
setAiConfigurationMode("custom");
}
}, [defaultRuntime, isCreateMode, open, runtime, runtimesLoading]);
function handleOpenChange(next: boolean) {
if (!next) {
setDisplayName("");
@@ -339,8 +377,9 @@ 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, isLoading: fileConfigLoading } =
useRuntimeFileConfigQuery(runtime, { enabled: open });
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, {
enabled: open,
});
function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) {
setAiConfigurationMode(nextMode);
setIsCustomProviderEditing(false);
@@ -359,9 +398,7 @@ export function AgentDefinitionDialog({
setProvider(nextPair.provider);
setModel(nextPair.model);
}
const { data: bakedEnvKeys, isLoading: bakedLoading } =
useBakedBuildEnvKeysQuery({ enabled: open });
const credentialSettled = !fileConfigLoading && !bakedLoading;
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
const localModeGate = React.useMemo(
() =>
computeLocalModeGate({
@@ -391,7 +428,6 @@ export function AgentDefinitionDialog({
// requiredEnvKeys: the gate already handles baked-, global-, and file-
// satisfied keys so no further filtering is needed.
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
@@ -399,18 +435,14 @@ export function AgentDefinitionDialog({
const fileProvider = runtimeFileConfig?.provider?.trim() ?? "";
const effectiveProvider =
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,
@@ -420,14 +452,6 @@ export function AgentDefinitionDialog({
secretEnvVar: topLevelSecretEnvVar,
value: apiKeyValue,
} = apiKeyFieldState;
// Provider required-ness is a static property of the field's visibility — 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 selected, which is incoherent (required means required, not
// "required until satisfied"). runtimeCanChooseLlmProvider is the authoritative
// gate: it tracks exactly when the provider picker is shown (Buzz Agent/Goose,
// plus runtime-less legacy/builtin definitions), so the required marker never
// drifts from whether Save actually needs a provider.
const providerIsRequired =
aiConfigurationMode === "custom" && runtimeCanChooseLlmProvider;
const modelFieldVisible =
@@ -444,7 +468,6 @@ export function AgentDefinitionDialog({
{ provider, model },
runtimeCanChooseLlmProvider,
);
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
const selectedRuntimeIsAvailable =
runtime.trim().length === 0 ||
selectedRuntime?.availability === "available";
@@ -464,29 +487,6 @@ export function AgentDefinitionDialog({
customAiPairSatisfied &&
!isAvatarUploadPending;
// Derive the single, deterministic reason the action is disabled from the
// same gate outputs that feed canSubmit — no policy is recomputed here.
// Precedence mirrors canSubmit's term order, so the reason is `null` exactly
// when the form can be submitted (transient Saving/Uploading states aside).
const submitBlockReason = personaSubmitBlock({
isPending,
isAvatarUploadPending,
displayNameEmpty: displayName.trim().length === 0,
isCreateMode,
runtimeChosen: runtime.trim().length > 0,
runtimeAvailable: selectedRuntimeIsAvailable,
createBackendBlocked: createSubmitBlocked,
allowlistEmpty: !personaBehaviorDraftValid(behaviorDraft),
aiConfigurationMode,
localModeSatisfied,
localModeMissingFields: localModeGate.missingNormalizedFields,
localModeMissingEnvKeys: localModeGate.missingEnvKeys,
customAiPairSatisfied,
runtimeNeedsProviderSelection: runtimeCanChooseLlmProvider,
customProviderEmpty: provider.trim().length === 0,
customModelEmpty: model.trim().length === 0,
});
// 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(
@@ -571,7 +571,10 @@ export function AgentDefinitionDialog({
]
: []),
...sortedRuntimes.map((candidate) => ({
disabled: isCreateMode && candidate.availability !== "available",
disabled:
isCreateMode &&
defaultRuntime !== null &&
candidate.availability !== "available",
label: `${formatRuntimeOptionLabel(candidate)}${
isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : ""
}`,
@@ -587,6 +590,9 @@ export function AgentDefinitionDialog({
value: runtime.trim(),
});
}
const runtimeSummaryLabel = selectedRuntime
? formatRuntimeOptionLabel(selectedRuntime)
: runtime.trim() || "Not configured";
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions
.filter((option) => option.id.trim().length > 0)
@@ -734,40 +740,27 @@ export function AgentDefinitionDialog({
headerClassName="pb-2"
title={title}
footer={
<div className="flex w-full items-center justify-between gap-3">
<div className="flex min-h-9 items-center">
{submitBlockReason ? (
<p
className="text-2xs text-muted-foreground"
data-testid="persona-dialog-submit-reason"
>
{submitBlockReason}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<Button
disabled={isPending || isAvatarUploadPending}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="persona-dialog-submit"
disabled={!canSubmit}
form="persona-dialog-form"
type="submit"
>
{isPending
? "Saving..."
: isAvatarUploadPending
? "Uploading..."
: submitLabel}
</Button>
</div>
<div className="flex w-full items-center justify-end gap-2">
<Button
disabled={isPending || isAvatarUploadPending}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="persona-dialog-submit"
disabled={!canSubmit}
form="persona-dialog-form"
type="submit"
>
{isPending
? "Saving..."
: isAvatarUploadPending
? "Uploading..."
: submitLabel}
</Button>
</div>
}
>
@@ -836,24 +829,6 @@ export function AgentDefinitionDialog({
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-runtime"
>
Agent harness
</label>
<PersonaDropdownField
disabled={isPending || runtimesLoading}
id="persona-runtime"
onValueChange={handleRuntimeDropdownChange}
options={runtimeDropdownOptions}
placeholder={blankRuntimeOptionLabel}
value={runtimeDropdownValue}
/>
{runtimeWarning}
</div>
{modelFieldVisible ? (
<AgentAiConfigurationModeField
mode={aiConfigurationMode}
@@ -862,110 +837,124 @@ export function AgentDefinitionDialog({
/>
) : null}
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
<div className="space-y-1.5">
<RequiredFieldLabel
htmlFor="persona-llm-provider"
isRequired={providerIsRequired}
>
LLM provider
{!providerIsRequired ? (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>
Optional
</span>
) : null}
</RequiredFieldLabel>
<PersonaDropdownField
disabled={isPending}
id="persona-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder="Choose a provider"
value={providerSelectValue}
/>
{showCustomProviderInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-custom-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{llmProviderFieldVisible &&
aiConfigurationMode === "custom" &&
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 && aiConfigurationMode === "custom" ? (
<PersonaModelField
disabled={isPending}
isExplicitModelRequired={isExplicitModelRequired}
model={model}
modelDiscoveryStatus={modelDiscoveryStatus}
modelDropdownOptions={modelDropdownOptions}
modelSelectValue={modelSelectValue}
onCustomModelChange={setModel}
showSharedComputeAutoHint={
isRelayMesh &&
modelSelectValue === AUTO_MODEL_DROPDOWN_VALUE
}
onModelValueChange={handleModelDropdownChange}
showCustomModelInput={showCustomModelInput}
transition={advancedFieldsTransition}
<div
className="space-y-5"
data-testid={`agent-${aiConfigurationMode}-configuration-section`}
>
{aiConfigurationMode === "custom" ? (
<AgentHarnessField
disabled={isPending || runtimesLoading}
onValueChange={handleRuntimeDropdownChange}
options={runtimeDropdownOptions}
placeholder={blankRuntimeOptionLabel}
value={runtimeDropdownValue}
warning={runtimeWarning}
/>
) : null}
</AnimatePresence>
{aiConfigurationMode === "defaults" ? (
runtimeCanChooseLlmProvider ? (
<AgentAiDefaultsNotice
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
explicitModel=""
explicitProvider=""
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
<div className="space-y-1.5">
<RequiredFieldLabel
htmlFor="persona-llm-provider"
isRequired={providerIsRequired}
>
LLM provider
{!providerIsRequired ? (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>
Optional
</span>
) : null}
</RequiredFieldLabel>
<PersonaDropdownField
disabled={isPending}
id="persona-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder="Choose a provider"
value={providerSelectValue}
/>
{showCustomProviderInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-custom-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{llmProviderFieldVisible &&
aiConfigurationMode === "custom" &&
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 && aiConfigurationMode === "custom" ? (
<PersonaModelField
disabled={isPending}
isExplicitModelRequired={isExplicitModelRequired}
model={model}
modelDiscoveryStatus={modelDiscoveryStatus}
modelDropdownOptions={modelDropdownOptions}
modelSelectValue={modelSelectValue}
onCustomModelChange={setModel}
showSharedComputeAutoHint={
isRelayMesh &&
modelSelectValue === AUTO_MODEL_DROPDOWN_VALUE
}
onModelValueChange={handleModelDropdownChange}
showCustomModelInput={showCustomModelInput}
transition={advancedFieldsTransition}
/>
) : null}
</AnimatePresence>
{aiConfigurationMode === "defaults" ? (
<AgentCreateAiDefaultsSummary
canChooseProvider={runtimeCanChooseLlmProvider}
harness={runtimeSummaryLabel}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
isConfigured={localModeGate.satisfied}
model={runtimeFileConfig?.model}
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
/>
) : (
<HarnessModelDefaultNotice model={runtimeFileConfig?.model} />
)
) : null}
) : null}
</div>
<AgentDefaultsDialog
onOpenChange={setAiDefaultsOpen}
@@ -983,6 +972,17 @@ export function AgentDefinitionDialog({
type="button"
>
<span>Advanced</span>
{localModeGate.missingEnvKeys.some((key) =>
advancedRequiredEnvKeys.includes(key),
) ? (
<span
aria-hidden="true"
className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive"
data-testid="persona-advanced-required-badge"
>
Required
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import type { PersonaDropdownOption } from "./agentConfigOptions";
import { PersonaDropdownField } from "./PersonaDropdownField";
export function AgentHarnessField({
disabled,
onValueChange,
options,
placeholder,
value,
warning,
}: {
disabled: boolean;
onValueChange: (value: string) => void;
options: PersonaDropdownOption[];
placeholder: string;
value: string;
warning?: ReactNode;
}) {
return (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-runtime"
>
Agent harness
</label>
<PersonaDropdownField
disabled={disabled}
id="persona-runtime"
onValueChange={onValueChange}
options={options}
placeholder={placeholder}
value={value}
/>
{warning}
</div>
);
}
@@ -81,6 +81,8 @@ import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { resolveModelFieldStatusMessage } from "./agentConfigControls";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
const ADVANCED_FIELDS_MOTION_TRANSITION = {
duration: 0.18,
@@ -367,26 +369,21 @@ export function AgentInstanceEditDialog({
} = useAgentDialogDefaults({ inheritedEnvVars, open });
// Runtime/provider-required credential state, derived from the PROSPECTIVE
// post-submit runtime — see the hook for the inherit-transition and
// Advanced-auto-expand rationale.
// post-submit runtime — see the hook for the inherit-transition rationale.
// Pass globalProvider so the hook uses it as a fallback when the per-agent
// 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,
settled: credentialSettled,
} = useRequiredCredentialState({
open,
prospectiveRuntimeId,
provider: inheritedSubmission.provider ?? "",
globalProvider: inheritedProviderDefault.value,
envVars: inheritedSubmission.envVars,
globalEnvVars: globalConfig.env_vars,
personaEnvVars: inheritHarness ? inheritedEnvVars : undefined,
});
const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } =
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 });
@@ -418,7 +415,7 @@ export function AgentInstanceEditDialog({
selectedRuntime,
});
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display + auto-open.
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display.
// 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
@@ -434,12 +431,9 @@ export function AgentInstanceEditDialog({
envVars,
fileSatisfiedEnvKeys,
globalEnvVars: globalConfig.env_vars,
open,
personaSatisfied,
provider: effectiveProvider,
requiredEnvKeys,
satisfactionSettled: credentialSettled,
setShowAdvancedFields,
});
const {
advancedRequiredEnvKeys,
@@ -449,7 +443,6 @@ export function AgentInstanceEditDialog({
secretEnvVar: topLevelSecretEnvVar,
value: apiKeyValue,
} = apiKeyFieldState;
// Clear model when provider scope changes and current model is no longer valid.
React.useEffect(() => {
if (
@@ -520,17 +513,6 @@ export function AgentInstanceEditDialog({
setInheritHarness(false);
}
// "Custom command" is the only selection whose command must be typed by
// the user, and that input lives inside the collapsed Advanced section.
// Auto-expand Advanced so the command field is visible — otherwise the
// user can Save without ever seeing it, leaving agentCommand equal to the
// original effective command (so the update is omitted) and the custom
// selection silently no-ops. See handleSubmit's customCommandPinned gate,
// which blocks Save when the revealed field is still empty.
if (isCustomCommand) {
setShowAdvancedFields(true);
}
// When switching to a catalog-known runtime, update the agent command to
// its resolved command so the command field stays consistent.
if (nextRuntime?.command) {
@@ -788,6 +770,11 @@ export function AgentInstanceEditDialog({
loadingValue: MODEL_DISCOVERY_LOADING_VALUE,
options: effectiveModelOptions,
});
const modelStatusMessage = resolveModelFieldStatusMessage({
discoveredModelOptions,
loading: modelDiscoveryLoading,
status: modelDiscoveryStatus,
});
// Provider field derived state
const trimmedProvider = provider.trim();
@@ -957,6 +944,35 @@ export function AgentInstanceEditDialog({
</p>
) : null}
</div>
{selectedRuntimeId === "custom" && !inheritHarness ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-command"
>
Agent command
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={updateMutation.isPending}
id="edit-agent-command"
onChange={(event) => setAgentCommand(event.target.value)}
placeholder="Full path or shell command"
value={agentCommand}
/>
</div>
</div>
) : null}
{/* LLM provider */}
{llmProviderFieldVisible ? (
<div className="space-y-1.5">
@@ -1074,15 +1090,11 @@ export function AgentInstanceEditDialog({
/>
</div>
) : null}
<p className="text-xs text-muted-foreground">
{modelDiscoveryLoading
? "Loading models..."
: modelDiscoveryStatus !== null
? modelDiscoveryStatus.message
: discoveredModelOptions !== null
? "Saved changes take effect on the next start."
: "Select a provider above to see available models."}
</p>
{modelStatusMessage ? (
<p className="text-xs text-muted-foreground">
{modelStatusMessage}
</p>
) : null}
</div>
<AgentAiDefaultsNotice
@@ -1109,6 +1121,11 @@ export function AgentInstanceEditDialog({
type="button"
>
<span>Advanced</span>
<AdvancedRequiredBadge
envVars={inheritedSubmission.envVars}
requiredEnvKeys={advancedRequiredEnvKeys}
testId="edit-agent-advanced-required-badge"
/>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
@@ -1129,7 +1146,6 @@ export function AgentInstanceEditDialog({
<EditAgentAdvancedFields
acpCommand={acpCommand}
agentArgs={agentArgs}
agentCommand={agentCommand}
autoRestartOnConfigChange={autoRestartOnConfigChange}
disabled={updateMutation.isPending}
envVars={envVars}
@@ -1150,11 +1166,9 @@ export function AgentInstanceEditDialog({
parallelism={parallelism}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
selectedRuntimeId={selectedRuntimeId}
systemPrompt={systemPrompt}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAgentCommandChange={setAgentCommand}
onAutoRestartChange={setAutoRestartOnConfigChange}
onEnvVarsChange={setEnvVars}
onInheritHarnessChange={setInheritHarness}
@@ -136,6 +136,7 @@ export function AgentsView() {
) : null}
</div>
}
className="mx-auto w-full max-w-[996px]"
description="Set up and manage your agents."
title="Agents"
/>
@@ -14,7 +14,6 @@ import { isBuzzAgentRuntime } from "./buzzAgentConfig";
export function EditAgentAdvancedFields({
acpCommand,
agentArgs,
agentCommand,
autoRestartOnConfigChange,
disabled,
envVars,
@@ -29,11 +28,9 @@ export function EditAgentAdvancedFields({
parallelism,
provider,
requiredEnvKeys,
selectedRuntimeId,
systemPrompt,
onAcpCommandChange,
onAgentArgsChange,
onAgentCommandChange,
onEnvVarsChange,
onInheritHarnessChange,
onParallelismChange,
@@ -42,7 +39,6 @@ export function EditAgentAdvancedFields({
}: {
acpCommand: string;
agentArgs: string;
agentCommand: string;
autoRestartOnConfigChange: boolean;
disabled: boolean;
envVars: EnvVarsValue;
@@ -65,11 +61,9 @@ export function EditAgentAdvancedFields({
/** Active LLM provider id — forwarded to BuzzAgentModelTuningFields for effort filtering. */
provider?: string;
requiredEnvKeys: readonly string[];
selectedRuntimeId: string;
systemPrompt: string;
onAcpCommandChange: (value: string) => void;
onAgentArgsChange: (value: string) => void;
onAgentCommandChange: (value: string) => void;
onEnvVarsChange: (value: EnvVarsValue) => void;
onInheritHarnessChange: (value: boolean) => void;
onParallelismChange: (value: string) => void;
@@ -124,37 +118,6 @@ export function EditAgentAdvancedFields({
</p>
</div>
{/* Custom agent command (when custom runtime) */}
{selectedRuntimeId === "custom" && !inheritHarness ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-command"
>
Agent command
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={disabled}
id="edit-agent-command"
onChange={(event) => onAgentCommandChange(event.target.value)}
placeholder="Full path or shell command"
value={agentCommand}
/>
</div>
</div>
) : null}
{/* Agent runtime args */}
<div className="space-y-1.5">
<label
@@ -23,7 +23,7 @@ import { CreateIdentityCard } from "./CreateIdentityCard";
import { TeamIdentityCard } from "./TeamIdentityCard";
const TEAM_CARD_COLUMN_CLASS = "w-full";
const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`;
const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`;
type TeamsSectionProps = {
teams: AgentTeam[];
@@ -61,7 +61,7 @@ type UnifiedAgentsSectionProps = {
};
const AGENT_CARD_COLUMN_CLASS = "w-full";
const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`;
const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`;
export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
const {
@@ -5,6 +5,7 @@ import {
MODEL_NO_MODELS_VALUE,
appendNoModelsSentinel,
resolveDefaultModelLabel,
resolveModelFieldStatusMessage,
} from "./agentConfigControls.tsx";
test("uses the harness-discovered default model label for an unset model", () => {
@@ -66,3 +67,41 @@ test("appendNoModelsSentinel_nonEmptyOptionsDiscoveryFinished_doesNotAddRow", ()
assert.equal(options.length, 1);
assert.equal(options[0].label, "Default model");
});
test("model status omits provider selection guidance before discovery", () => {
assert.equal(
resolveModelFieldStatusMessage({
discoveredModelOptions: null,
loading: false,
status: null,
}),
null,
);
});
test("model status preserves loading, discovery, and saved-state messages", () => {
assert.equal(
resolveModelFieldStatusMessage({
discoveredModelOptions: null,
loading: true,
status: null,
}),
"Loading models...",
);
assert.equal(
resolveModelFieldStatusMessage({
discoveredModelOptions: null,
loading: false,
status: { message: "Couldn't load models", tone: "warning" },
}),
"Couldn't load models",
);
assert.equal(
resolveModelFieldStatusMessage({
discoveredModelOptions: [],
loading: false,
status: null,
}),
"Saved changes take effect on the next start.",
);
});
@@ -20,6 +20,8 @@ import {
getModelSelectValue,
getPersonaProviderOptions,
hasPersonaModelOption,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
providerDisplayLabel,
type PersonaModelOption,
} from "./agentConfigOptions";
@@ -54,11 +56,59 @@ export function appendNoModelsSentinel(
return options;
}
export function resolveModelFieldStatusMessage({
discoveredModelOptions,
loading,
status,
}: {
discoveredModelOptions: readonly PersonaModelOption[] | null;
loading: boolean;
status: PersonaModelDiscoveryStatus | null;
}): string | null {
if (loading) return "Loading models...";
if (status !== null) return status.message;
return discoveredModelOptions !== null
? "Saved changes take effect on the next start."
: null;
}
function optionTestId(testId: string | undefined, value: string) {
if (!testId) return undefined;
return `${testId}-option-${value || "empty"}`;
}
export function AgentConfigTextInput({
className,
usePersonaInputStyle = false,
...props
}: React.ComponentProps<typeof Input> & {
usePersonaInputStyle?: boolean;
}) {
const input = (
<Input
{...props}
className={cn(
usePersonaInputStyle && "h-8 px-0 py-0 leading-6",
usePersonaInputStyle && PERSONA_FIELD_CONTROL_CLASS,
className,
)}
/>
);
return usePersonaInputStyle ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
{input}
</div>
) : (
input
);
}
export function AgentDropdownSelect({
ariaRequired,
className,
@@ -307,6 +357,7 @@ export function AgentModelField({
showStatusMessage = true,
showCustomModelOption = true,
useChevronIcon = false,
usePersonaInputStyle = false,
}: {
disabled: boolean;
discoveredModelOptions: readonly PersonaModelOption[] | null;
@@ -352,6 +403,8 @@ export function AgentModelField({
showCustomModelOption?: boolean;
/** Render a controlled chevron instead of the native select indicator. */
useChevronIcon?: boolean;
/** Match custom agent text inputs when this field is shown in that flow. */
usePersonaInputStyle?: boolean;
}) {
const trimmedModel = model.trim();
const isSharedCompute = provider?.trim() === "relay-mesh";
@@ -474,6 +527,11 @@ export function AgentModelField({
!isCustomModelEditing
? "Loading models..."
: placeholder;
const statusMessage = resolveModelFieldStatusMessage({
discoveredModelOptions,
loading: modelDiscoveryLoading,
status: modelDiscoveryStatus,
});
const modelSelect = useCustomSelect ? (
<AgentDropdownSelect
@@ -537,25 +595,18 @@ export function AgentModelField({
modelSelect
)}
{showCustomModelInput ? (
<Input
<AgentConfigTextInput
aria-label="Custom model ID"
autoCorrect="off"
disabled={disabled}
onChange={(event) => onModelChange(event.target.value)}
placeholder="Custom model ID"
usePersonaInputStyle={usePersonaInputStyle}
value={model}
/>
) : null}
{showStatusMessage ? (
<p className="text-xs text-muted-foreground">
{modelDiscoveryLoading
? "Loading models..."
: modelDiscoveryStatus !== null
? modelDiscoveryStatus.message
: discoveredModelOptions !== null
? "Saved changes take effect on the next start."
: "Select a provider above to see available models."}
</p>
{showStatusMessage && statusMessage ? (
<p className="text-xs text-muted-foreground">{statusMessage}</p>
) : null}
</div>
);
@@ -21,6 +21,7 @@ import test from "node:test";
import {
CANONICAL_CONFIG_BEHAVIORS,
resolveDisclosure,
shouldRevealDependentConfigFields,
shouldRenderModelControl,
shouldShowModelStatusMessage,
} from "./AgentConfigFields.tsx";
@@ -60,6 +61,48 @@ test("onboarding-essential hides power tools but never the effort field", () =>
});
});
test("progressive defaults keep full disclosure", () => {
assert.deepEqual(
resolveDisclosure("progressive-defaults"),
resolveDisclosure("full"),
);
});
test("progressive defaults wait for a provider only when the harness needs one", () => {
assert.equal(
shouldRevealDependentConfigFields({
disclosure: "progressive-defaults",
providerFieldVisible: true,
providerValue: "",
}),
false,
);
assert.equal(
shouldRevealDependentConfigFields({
disclosure: "progressive-defaults",
providerFieldVisible: true,
providerValue: "anthropic",
}),
true,
);
assert.equal(
shouldRevealDependentConfigFields({
disclosure: "progressive-defaults",
providerFieldVisible: false,
providerValue: "",
}),
true,
);
assert.equal(
shouldRevealDependentConfigFields({
disclosure: "full",
providerFieldVisible: true,
providerValue: "",
}),
true,
);
});
// ── shouldShowModelStatusMessage ──────────────────────────────────────────────
// The onboarding-essential preset sets showDescriptions=false. Discovery
// warnings must bypass the preset so first-run failures are never invisible.
@@ -542,86 +542,6 @@ test("editAgent_resolveAgentCommandUpdate_inheritSentinelOnlyWhenPinToClear", ()
);
});
test("editAgent_customCommandSelected_autoExpandsAdvancedSection", () => {
// Selecting "Custom command" must reveal the Advanced command input, which is
// otherwise collapsed. Without this the user can Save without ever seeing the
// field, leaving agentCommand equal to the original effective command (so the
// update is omitted) and the custom selection silently no-ops.
let showAdvancedFields = false; // starts collapsed on open
const NO_RUNTIME_DROPDOWN_VALUE = "__none__";
const nextValue = "custom";
const nextRuntimeId =
nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue;
const resolvedRuntimeId = nextRuntimeId || "custom";
const isCustomCommand = resolvedRuntimeId === "custom";
// Mirror the handler's auto-expand branch.
if (isCustomCommand) {
showAdvancedFields = true;
}
assert.equal(
showAdvancedFields,
true,
"selecting 'Custom command' must auto-expand Advanced so the command input is visible",
);
});
test("editAgent_missingRequiredEnvKey_autoExpandsAdvancedOnTransition", () => {
// Codex P2: when a provider change makes a credential newly required, the
// EnvVarsEditor lives inside the collapsed Advanced section, so the amber
// required row would stay unmounted (invisible) while Save is disabled. The
// effect auto-expands Advanced on the missing→present-requirement transition.
let showAdvancedFields = false; // collapsed by default on open
let previousMissing = false;
// Mirror the effect's transition guard.
function applyMissingEffect(requiredEnvKeyMissing) {
if (requiredEnvKeyMissing && !previousMissing) {
showAdvancedFields = true;
}
previousMissing = requiredEnvKeyMissing;
}
// Initial render: buzz-agent with no provider — nothing required yet.
applyMissingEffect(
hasMissingRequiredEnvKey(requiredCredentialEnvKeys("buzz-agent", ""), {}),
);
assert.equal(
showAdvancedFields,
false,
"Advanced stays collapsed while no credential is required",
);
// User picks anthropic → ANTHROPIC_API_KEY becomes required and is unset.
applyMissingEffect(
hasMissingRequiredEnvKey(
requiredCredentialEnvKeys("buzz-agent", "anthropic"),
{},
),
);
assert.equal(
showAdvancedFields,
true,
"Advanced auto-expands when a required credential is newly missing",
);
// User fills the key, then collapses Advanced manually — no re-expand.
showAdvancedFields = false;
applyMissingEffect(
hasMissingRequiredEnvKey(
requiredCredentialEnvKeys("buzz-agent", "anthropic"),
{ ANTHROPIC_API_KEY: "sk-ant-test" },
),
);
assert.equal(
showAdvancedFields,
false,
"Advanced does not re-expand once the required credential is filled",
);
});
test("editAgent_missingRequiredEnvKey_blocksSaveViaValidity", () => {
// The block-save gate is folded into computeEditAgentFormValidity so the
// Save button disables when a runtime/provider-required credential is unset.
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getGlobalAgentCredentialState } from "./globalAgentCredentialState.ts";
test("global defaults accept an advanced credential set in runtime config", () => {
const state = getGlobalAgentCredentialState({
bakedEnvKeys: [],
envVars: {},
provider: "databricks_v2",
runtimeFileConfig: {
provider: "databricks_v2",
model: "goose-claude-4-6-opus",
satisfiedEnvKeys: ["DATABRICKS_HOST"],
},
runtimeId: "goose",
});
assert.equal(state.advancedCredentialMissing, false);
assert.equal(state.credentialsValid, true);
assert.deepEqual(state.advancedRequiredEnvKeys, []);
assert.deepEqual(state.advancedFileSatisfiedEnvKeys, ["DATABRICKS_HOST"]);
});
test("an explicit empty global value shadows the runtime config", () => {
const state = getGlobalAgentCredentialState({
bakedEnvKeys: [],
envVars: { DATABRICKS_HOST: "" },
provider: "databricks_v2",
runtimeFileConfig: {
provider: "databricks_v2",
model: "goose-claude-4-6-opus",
satisfiedEnvKeys: ["DATABRICKS_HOST"],
},
runtimeId: "goose",
});
assert.equal(state.advancedCredentialMissing, true);
assert.equal(state.credentialsValid, false);
assert.deepEqual(state.advancedRequiredEnvKeys, ["DATABRICKS_HOST"]);
assert.deepEqual(state.advancedFileSatisfiedEnvKeys, []);
});
test("global defaults accept a provider key set in runtime config", () => {
const state = getGlobalAgentCredentialState({
bakedEnvKeys: [],
envVars: {},
provider: "openai",
runtimeFileConfig: {
provider: "openai",
model: "gpt-5.5",
satisfiedEnvKeys: ["OPENAI_COMPAT_API_KEY"],
},
runtimeId: "goose",
});
assert.equal(state.apiKeyFileSatisfied, true);
assert.equal(state.apiKeyInherited, true);
assert.equal(state.credentialsValid, true);
});
@@ -0,0 +1,70 @@
import type { RuntimeFileConfigSubset } from "@/shared/api/tauri";
import {
getBakedSatisfiedEnvKeys,
getProviderApiKeyEnvVar,
requiredCredentialEnvKeys,
} from "@/features/agents/ui/agentConfigOptions";
export function getGlobalAgentCredentialState({
bakedEnvKeys,
envVars,
provider,
runtimeFileConfig,
runtimeId,
}: {
bakedEnvKeys: readonly string[];
envVars: Record<string, string>;
provider: string;
runtimeFileConfig: RuntimeFileConfigSubset | null | undefined;
runtimeId: string;
}) {
const requiredEnvKeys = requiredCredentialEnvKeys(runtimeId, provider);
const apiKeyEnvVar = getProviderApiKeyEnvVar(provider);
const bakedSatisfiedEnvKeys = getBakedSatisfiedEnvKeys(
requiredEnvKeys,
envVars,
bakedEnvKeys,
);
const fileSatisfiedEnvKeys = requiredEnvKeys.filter(
(key) =>
!(key in envVars) &&
!bakedSatisfiedEnvKeys.includes(key) &&
(runtimeFileConfig?.satisfiedEnvKeys.includes(key) ?? false),
);
const displayedRequiredEnvKeys = requiredEnvKeys.filter(
(key) =>
!bakedSatisfiedEnvKeys.includes(key) &&
!fileSatisfiedEnvKeys.includes(key),
);
const advancedRequiredEnvKeys = displayedRequiredEnvKeys.filter(
(key) => key !== apiKeyEnvVar,
);
const advancedFileSatisfiedEnvKeys = fileSatisfiedEnvKeys.filter(
(key) => key !== apiKeyEnvVar,
);
const apiKeyValue = apiKeyEnvVar ? (envVars[apiKeyEnvVar] ?? "") : "";
const apiKeyFileSatisfied =
apiKeyEnvVar !== null && fileSatisfiedEnvKeys.includes(apiKeyEnvVar);
const apiKeyInherited =
apiKeyEnvVar !== null &&
apiKeyValue.length === 0 &&
(bakedSatisfiedEnvKeys.includes(apiKeyEnvVar) || apiKeyFileSatisfied);
const advancedCredentialMissing = advancedRequiredEnvKeys.some(
(key) => (envVars[key] ?? "").trim().length === 0,
);
const apiKeyMissing =
apiKeyEnvVar !== null &&
!apiKeyInherited &&
apiKeyValue.trim().length === 0;
return {
advancedCredentialMissing,
advancedFileSatisfiedEnvKeys,
advancedRequiredEnvKeys,
apiKeyEnvVar,
apiKeyFileSatisfied,
apiKeyInherited,
apiKeyValue,
credentialsValid: !advancedCredentialMissing && !apiKeyMissing,
};
}
@@ -100,7 +100,7 @@ export function resolveAgentCommandUpdate(input: {
* config contribute no entries, so this never blocks on out-of-band auth.
*/
export function hasMissingRequiredEnvKey(
requiredEnvKeys: string[],
requiredEnvKeys: readonly string[],
envVars: Record<string, string>,
): boolean {
return requiredEnvKeys.some((key) => (envVars[key] ?? "").length === 0);
@@ -1,160 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { personaSubmitBlock } from "./personaSubmitBlock.ts";
/** A fully valid, submittable form: personaSubmitBlock returns null. */
function submittable(overrides = {}) {
return {
isPending: false,
isAvatarUploadPending: false,
displayNameEmpty: false,
isCreateMode: true,
runtimeChosen: true,
runtimeAvailable: true,
createBackendBlocked: false,
allowlistEmpty: false,
aiConfigurationMode: "defaults",
localModeSatisfied: true,
localModeMissingFields: [],
localModeMissingEnvKeys: [],
customAiPairSatisfied: true,
runtimeNeedsProviderSelection: true,
customProviderEmpty: false,
customModelEmpty: false,
...overrides,
};
}
test("a valid form has no disabled reason", () => {
assert.equal(personaSubmitBlock(submittable()), null);
});
test("missing name is reported first", () => {
assert.equal(
personaSubmitBlock(submittable({ displayNameEmpty: true })),
"Enter a name for this agent.",
);
});
test("Buzz Agent + Use AI defaults with no global provider/model names the fix", () => {
const reason = personaSubmitBlock(
submittable({
aiConfigurationMode: "defaults",
localModeSatisfied: false,
localModeMissingFields: ["provider", "model"],
}),
);
assert.match(reason, /global AI defaults are incomplete/);
assert.match(reason, /a provider and a model/);
assert.match(reason, /Settings → AI defaults/);
});
test("incomplete defaults also names missing credential keys", () => {
const reason = personaSubmitBlock(
submittable({
localModeSatisfied: false,
localModeMissingFields: [],
localModeMissingEnvKeys: ["ANTHROPIC_API_KEY"],
}),
);
assert.match(reason, /a value for ANTHROPIC_API_KEY/);
});
test("the reason disappears once the blocking input is corrected", () => {
const blocked = submittable({
localModeSatisfied: false,
localModeMissingFields: ["provider", "model"],
});
assert.notEqual(personaSubmitBlock(blocked), null);
// Correct the blocking input: defaults now resolve.
const corrected = {
...blocked,
localModeSatisfied: true,
localModeMissingFields: [],
};
assert.equal(personaSubmitBlock(corrected), null);
});
test("create mode requires a chosen, available runtime", () => {
assert.equal(
personaSubmitBlock(submittable({ runtimeChosen: false })),
"Choose where this agent runs.",
);
assert.equal(
personaSubmitBlock(submittable({ runtimeAvailable: false })),
"The selected runtime isn't available on this machine.",
);
});
test("runtime gates do not apply in edit mode", () => {
assert.equal(
personaSubmitBlock(
submittable({ isCreateMode: false, runtimeChosen: false }),
),
null,
);
});
test("empty allowlist is reported (create and edit)", () => {
const reason = personaSubmitBlock(
submittable({ isCreateMode: false, allowlistEmpty: true }),
);
assert.match(reason, /allowed sender/);
});
test("Customize with an empty pair but satisfied global fallback points at the pair", () => {
const reason = personaSubmitBlock(
submittable({
aiConfigurationMode: "custom",
localModeSatisfied: true,
customAiPairSatisfied: false,
customProviderEmpty: true,
customModelEmpty: true,
}),
);
assert.match(reason, /Select a provider and a model/);
assert.match(reason, /Use AI defaults/);
});
test("Customize on Codex/Claude asks only for a model, never a provider", () => {
const reason = personaSubmitBlock(
submittable({
aiConfigurationMode: "custom",
customAiPairSatisfied: false,
runtimeNeedsProviderSelection: false,
customProviderEmpty: true,
customModelEmpty: true,
}),
);
assert.match(reason, /Select a model/);
assert.doesNotMatch(reason, /provider/);
assert.match(reason, /Use harness defaults/);
assert.doesNotMatch(reason, /Use AI defaults/);
});
test("precedence: a missing name outranks incomplete AI defaults", () => {
assert.equal(
personaSubmitBlock(
submittable({
displayNameEmpty: true,
localModeSatisfied: false,
localModeMissingFields: ["provider", "model"],
}),
),
"Enter a name for this agent.",
);
});
test("in-flight save/upload shows no reason (the button label communicates it)", () => {
assert.equal(
personaSubmitBlock(
submittable({ isPending: true, displayNameEmpty: true }),
),
null,
);
assert.equal(
personaSubmitBlock(submittable({ isAvatarUploadPending: true })),
null,
);
});
@@ -1,138 +0,0 @@
import type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy";
/**
* Inputs for {@link personaSubmitBlock}. Every field is an OUTPUT of a gate the
* dialog already computes for `canSubmit` this module maps those outputs to a
* single human-readable reason. It must not recompute policy: the derivation
* stays a pure function of the gate results so the message can never disagree
* with whether the button is actually disabled.
*/
export type PersonaSubmitBlockInput = {
/** A save/create request is in flight (button shows "Saving..."). */
isPending: boolean;
/** The avatar upload is in flight (button shows "Uploading..."). */
isAvatarUploadPending: boolean;
/** Trimmed display name is empty. */
displayNameEmpty: boolean;
/** Create (new definition) vs edit (existing). Some gates are create-only. */
isCreateMode: boolean;
/** A runtime has been chosen (create-only gate). */
runtimeChosen: boolean;
/** The chosen runtime is available on this machine (create-only gate). */
runtimeAvailable: boolean;
/** The remote / where-to-run backend selection is incomplete (create-only). */
createBackendBlocked: boolean;
/** Respond-to allowlist mode is selected but the allowlist is empty. */
allowlistEmpty: boolean;
/** Selected AI configuration mode: inherit global defaults vs customize. */
aiConfigurationMode: AgentAiConfigurationMode;
/** `computeLocalModeGate(...).satisfied` — resolved AI config is complete. */
localModeSatisfied: boolean;
/** `computeLocalModeGate(...).missingNormalizedFields`, e.g. ["provider"]. */
localModeMissingFields: readonly string[];
/** `computeLocalModeGate(...).missingEnvKeys` — required credentials unset. */
localModeMissingEnvKeys: readonly string[];
/** `agentAiConfigurationModeSatisfied(...)` for the Customize pair. */
customAiPairSatisfied: boolean;
/** Runtime exposes a provider picker (Buzz Agent / Goose), not Codex/Claude. */
runtimeNeedsProviderSelection: boolean;
/** Customize provider field is empty. */
customProviderEmpty: boolean;
/** Customize model field is empty. */
customModelEmpty: boolean;
};
function joinWithAnd(parts: readonly string[]): string {
if (parts.length <= 1) return parts[0] ?? "";
if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`;
}
/**
* Describe the concrete missing pieces behind an unsatisfied AI-config gate,
* naming the actual fix rather than a generic "configuration incomplete".
*/
function describeMissingAiPieces(
fields: readonly string[],
envKeys: readonly string[],
): string {
const parts: string[] = [];
if (fields.includes("provider")) parts.push("a provider");
if (fields.includes("model")) parts.push("a model");
for (const key of envKeys) parts.push(`a value for ${key}`);
return joinWithAnd(parts);
}
/**
* Human-readable reason the Create/Save button is disabled, or `null` when the
* form can be submitted. Precedence mirrors the `canSubmit` term order in
* AgentDefinitionDialog so the surfaced reason is deterministic and always the
* first blocking input correcting it makes the reason advance or disappear.
*
* While a request or avatar upload is in flight the button communicates the
* progress itself ("Saving..." / "Uploading..."), so no reason is returned.
*/
export function personaSubmitBlock(
input: PersonaSubmitBlockInput,
): string | null {
if (input.isPending || input.isAvatarUploadPending) {
return null;
}
// 1. Required definition fields.
if (input.displayNameEmpty) {
return "Enter a name for this agent.";
}
// 24. Create-only runtime / backend gates.
if (input.isCreateMode) {
if (!input.runtimeChosen) {
return "Choose where this agent runs.";
}
if (!input.runtimeAvailable) {
return "The selected runtime isn't available on this machine.";
}
if (input.createBackendBlocked) {
return "Finish configuring the remote backend before creating this agent.";
}
}
// 5. Access / allowlist crash-loop guard (create and edit).
if (input.allowlistEmpty) {
return "Add at least one allowed sender, or change who this agent responds to.";
}
// 6. Resolved AI configuration (provider/model/credentials) incomplete.
if (!input.localModeSatisfied) {
const missing = describeMissingAiPieces(
input.localModeMissingFields,
input.localModeMissingEnvKeys,
);
if (input.aiConfigurationMode === "defaults") {
const detail = missing ? ` — missing ${missing}` : "";
return `Your global AI defaults are incomplete${detail}. Set them in Settings → AI defaults, or choose Customize to configure this agent directly.`;
}
return missing
? `This agent's AI configuration is missing ${missing}.`
: "Complete this agent's AI configuration.";
}
// 7. Customize pair incomplete (form provider/model empty while a global
// fallback keeps localMode satisfied). Provider only counts where the runtime
// exposes a picker — Codex/Claude drive their own provider.
if (!input.customAiPairSatisfied) {
const needProvider =
input.runtimeNeedsProviderSelection && input.customProviderEmpty;
const pieces: string[] = [];
if (needProvider) pieces.push("a provider");
if (input.customModelEmpty) pieces.push("a model");
const what =
pieces.length > 0 ? joinWithAnd(pieces) : "the AI configuration";
const defaultsLabel = input.runtimeNeedsProviderSelection
? "Use AI defaults"
: "Use harness defaults";
return `Select ${what} for this agent, or switch to ${defaultsLabel}.`;
}
return null;
}
@@ -105,26 +105,20 @@ export function useProviderApiKeyFieldState({
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(
return React.useMemo(
() =>
getProviderApiKeyFieldState({
bakedEnvKeys,
@@ -147,26 +141,4 @@ export function useProviderApiKeyFieldState({
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;
}
@@ -21,8 +21,6 @@ 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;
}
/**
@@ -35,7 +33,8 @@ export interface RequiredCredentialState {
* actually be saved.
*
* The caller owns the visibility policy: top-level API-key fields are already
* visible, while non-secret Advanced-only keys can open that section.
* visible, while non-secret keys remain available under user-controlled
* Advanced disclosure.
*
* `globalProvider` is used as the fallback when the per-agent provider is
* empty without it, a global-provider-only config produces no required keys
@@ -78,13 +77,12 @@ export function useRequiredCredentialState(params: {
? provider.trim() || globalProvider.trim()
: "";
const { data: runtimeFileConfig, isLoading: fileConfigLoading } =
useRuntimeFileConfigQuery(prospectiveRuntimeId, { enabled: open });
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(
prospectiveRuntimeId,
{ enabled: open },
);
const { data: bakedEnvKeys, isLoading: bakedLoading } =
useBakedBuildEnvKeysQuery({ enabled: open });
const settled = !fileConfigLoading && !bakedLoading;
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
// All required keys for this runtime + provider combination.
const allRequiredKeys = React.useMemo(
@@ -137,6 +135,5 @@ export function useRequiredCredentialState(params: {
requiredEnvKeys,
fileSatisfiedEnvKeys,
requiredEnvKeyMissing,
settled,
};
}
@@ -1,6 +1,9 @@
import * as React from "react";
import { useAcpRuntimesQuery } from "@/features/agents/hooks";
import {
useAcpRuntimesQuery,
useRuntimeFileConfigQuery,
} from "@/features/agents/hooks";
import {
AgentConfigFields,
EMPTY_GLOBAL_CONFIG,
@@ -138,6 +141,8 @@ function AgentDefaultsSection({
[config.preferred_runtime, readyRuntimes],
);
const selectedRuntimeId = selectedRuntime?.id ?? "";
const { data: runtimeFileConfig } =
useRuntimeFileConfigQuery(selectedRuntimeId);
const configSurfaceLoading = isLoading || runtimesQuery.isLoading;
const configSurfaceError =
@@ -235,6 +240,7 @@ function AgentDefaultsSection({
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
placeholderClassName="text-foreground/70"
runtimeFileConfig={runtimeFileConfig}
selectClassName="h-12 rounded-2xl border-foreground/15 bg-white px-4 py-2 text-sm shadow-none hover:bg-white/95"
disclosure="onboarding-essential"
unstyled
+7 -3
View File
@@ -49,6 +49,7 @@ import type {
import type {
RawAcpRuntimeCatalogEntry,
RawInstallRuntimeResult,
RuntimeFileConfigSubset,
} from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -355,6 +356,8 @@ type E2eConfig = {
model: string | null;
preferred_runtime?: string | null;
};
/** File-layer config returned by runtime id. */
runtimeFileConfigs?: Record<string, RuntimeFileConfigSubset | null>;
/** Baked build env returned by the display and key-name Tauri commands. */
bakedBuildEnv?: Array<{
key: string;
@@ -10293,9 +10296,10 @@ export function maybeInstallE2eTauriMocks() {
return buildMockConfigSurface(configArgs.pubkey);
}
case "get_runtime_file_config": {
// No harness config file in the E2E environment — return null so
// dialogs fall back to normal required-field evaluation.
return null;
const runtimeId = (payload as { runtimeId?: string } | null | undefined)
?.runtimeId;
if (!runtimeId) return null;
return config.mock?.runtimeFileConfigs?.[runtimeId] ?? null;
}
case "get_global_agent_config": {
// Return the mutable persisted mock value, seeded from the test config.
@@ -144,7 +144,7 @@ test.describe("agent lifecycle feedback screenshots", () => {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: {},
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigRestartedCount: 2,
});
@@ -181,7 +181,7 @@ test.describe("agent lifecycle feedback screenshots", () => {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: {},
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
});
@@ -301,7 +301,7 @@ test.describe("agent lifecycle feedback screenshots", () => {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: {},
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigRestartedCount: 1,
});
@@ -329,7 +329,7 @@ test.describe("agent lifecycle feedback screenshots", () => {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: {},
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigFailedRestartCount: 1,
});
@@ -366,7 +366,7 @@ test.describe("agent lifecycle feedback screenshots", () => {
globalAgentConfig: {
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: {},
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
},
globalConfigSaveDelayMs: 2_000,
});
@@ -174,6 +174,8 @@ test.describe("agent provider dropdown screenshots", () => {
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
// Regression: the runtime trigger must not be empty — the auto-seed effect
// must have run and selected the app default (buzz-agent in the mock catalog).
const runtimeTrigger = dialog.locator("#persona-runtime");
@@ -182,8 +184,6 @@ test.describe("agent provider dropdown screenshots", () => {
timeout: 8_000,
});
await dialog.getByRole("tab", { name: "Customize for this agent" }).click();
// Regression: the model combobox must appear (modelFieldVisible = true once
// runtime is non-empty) and model discovery must have run, populating it.
const modelCombobox = dialog.getByRole("combobox", { name: /model/i });
@@ -250,6 +250,6 @@ test.describe("agent provider dropdown screenshots", () => {
await expect(
dialog.getByRole("combobox", { name: /model/i }),
).toBeVisible();
await expect(dialog.getByText("Model changes apply only")).toBeVisible();
await expect(dialog.getByText("Model changes apply only")).toHaveCount(0);
});
});
@@ -225,7 +225,8 @@ test.describe("agent readiness gate screenshots", () => {
});
});
// Shot 05: claude runtime (CLI-login) — provider/model not required, submit enabled.
// Shot 05: claude runtime (CLI-login) — provider not required; an explicit
// model completes the custom configuration and enables submit.
// Override the catalog to make claude fully available so it appears in the dropdown.
test("05-create-cli-login-runtime-no-provider-required", async ({ page }) => {
await installMockBridge(page, {
@@ -263,6 +264,7 @@ test.describe("agent readiness gate screenshots", () => {
});
await openCreateDialog(page);
await page.getByRole("tab", { name: "Customize for this agent" }).click();
// Switch the auto-selected buzz-agent runtime to Claude Code.
await selectDropdownOption(
@@ -271,9 +273,10 @@ test.describe("agent readiness gate screenshots", () => {
"Claude Code",
);
// Provider/model fields hidden for CLI-login runtimes.
// Provider stays hidden for CLI-login runtimes. Customize still requires
// an explicit model choice.
await expect(page.locator("#persona-llm-provider")).not.toBeVisible();
// Submit enabled without provider/model.
await setCustomModel(page, "claude-opus-4-6");
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
timeout: 5_000,
});
+58
View File
@@ -153,6 +153,64 @@ test.describe("edit agent dialog", () => {
);
});
test("keeps the custom command visible without opening Advanced", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
const advanced = page.getByRole("button", {
name: "Advanced",
exact: true,
});
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await pickDropdownOption(page, "edit-agent-runtime", "Custom command");
await expect(page.locator("#edit-agent-command")).toBeVisible();
await expect(advanced).toHaveAttribute("aria-expanded", "false");
});
test("marks a missing advanced credential without opening Advanced", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PUBKEY,
name: AGENT_NAME,
status: "stopped",
channelNames: ["agents"],
},
],
});
await openEditDialog(page);
const advanced = page.getByRole("button", {
name: "Advanced",
exact: true,
});
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await pickDropdownOption(page, "edit-agent-llm-provider", "Databricks v2");
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("edit-agent-advanced-required-badge"),
).toHaveText("Required");
await expect(page.getByTestId("edit-agent-dialog-submit")).toBeDisabled();
await advanced.click();
await expect(page.getByLabel("Value for DATABRICKS_HOST")).toBeVisible();
});
test("shows baked defaults in the instance editor", async ({ page }) => {
await installMockBridge(page, {
bakedBuildEnv: BAKED_DEFAULTS,
@@ -253,6 +253,45 @@ test.describe("global agent config screenshots", () => {
expect(saved).toMatchObject({ preferred_runtime: "codex" });
});
test("defaults honor credentials set in the harness config file", async ({
page,
}) => {
await installMockBridge(page, {
globalAgentConfig: {
preferred_runtime: "goose",
provider: "databricks_v2",
model: "goose-claude-4-6-opus",
env_vars: {},
},
runtimeFileConfigs: {
goose: {
provider: "databricks_v2",
model: "goose-claude-4-6-opus",
satisfiedEnvKeys: ["DATABRICKS_HOST"],
},
},
});
await openAiDefaultsSettings(page);
const advanced = page.getByTestId("global-agent-advanced-toggle");
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("global-agent-advanced-required-badge"),
).toHaveCount(0);
await page.getByTestId("global-agent-model").click();
await page.getByTestId("global-agent-model-option-gpt-5.5").click();
await expect(
page.getByRole("button", { name: "Save defaults" }),
).toBeEnabled();
await advanced.click();
await expect(page.getByTestId("env-vars-file-satisfied-key")).toHaveText(
"DATABRICKS_HOST",
);
});
test("02-create-global-provider-shows-top-level-api-key", async ({
page,
}) => {
@@ -267,6 +306,11 @@ test.describe("global agent config screenshots", () => {
await openCreateDialog(page);
await customizeAgentAi(page);
await expect(
page
.getByTestId("agent-custom-configuration-section")
.locator("#persona-runtime"),
).toBeVisible();
await expect(page.getByLabel("Anthropic API Key")).toBeVisible({
timeout: 10_000,
});
@@ -274,6 +318,27 @@ test.describe("global agent config screenshots", () => {
page.getByRole("button", { name: "Advanced", exact: true }),
).toHaveAttribute("aria-expanded", "false");
await expect(page.getByTestId("env-vars-required-key")).not.toBeVisible();
await waitForAnimations(page);
await page.getByRole("dialog").screenshot({
path: `${SHOTS}/02-create-custom-agent-configuration.png`,
});
const advanced = page.getByRole("button", {
name: "Advanced",
exact: true,
});
await selectDropdownOption(
page,
page.locator("#persona-llm-provider"),
"Databricks v2",
);
await expect(advanced).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("persona-advanced-required-badge"),
).toHaveText("Required");
await advanced.click();
await expect(advanced).toHaveAttribute("aria-expanded", "true");
});
test("03-global-env-satisfies-required-key", async ({ page }) => {
@@ -360,6 +425,11 @@ test.describe("global agent config screenshots", () => {
value: "high",
masked: false,
},
{
key: "ANTHROPIC_API_KEY",
value: "sk-ant-baked-test",
masked: true,
},
],
});
@@ -390,13 +460,99 @@ test.describe("global agent config screenshots", () => {
timeout: 10_000,
});
// The footer must explain WHY it is disabled (regression guard for the
// submitBlockReason wiring, not just the boolean gate): defaults mode with
// no resolvable provider names the missing piece and points to Settings.
const reason = page.getByTestId("persona-dialog-submit-reason");
await expect(reason).toBeVisible({ timeout: 10_000 });
await expect(reason).toContainText("provider");
await expect(reason).toContainText("Settings → AI defaults");
const defaults = page.getByTestId("agent-ai-defaults-notice");
await expect(defaults).toContainText("Global defaults not set");
await expect(defaults.getByText("Harness", { exact: true })).toHaveCount(0);
await expect(defaults.getByText("Provider", { exact: true })).toHaveCount(
0,
);
await expect(defaults.getByText("Model", { exact: true })).toHaveCount(0);
const setDefaults = defaults.getByRole("button", {
name: "Set",
});
await expect(setDefaults).toBeVisible();
await expect(page.getByTestId("persona-dialog-submit-reason")).toHaveCount(
0,
);
await setDefaults.click();
const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog");
await expect(defaultsDialog).toBeVisible();
await expect(
defaultsDialog.getByTestId("global-agent-config-fields"),
).toBeVisible();
await expect(defaultsDialog.getByTestId("global-agent-model")).toHaveCount(
0,
);
const harness = defaultsDialog.getByTestId("global-agent-default-harness");
await expect(harness).toHaveText("Buzz Agent");
const provider = defaultsDialog.getByTestId("global-agent-provider");
await expect(provider).toBeVisible();
await waitForAnimations(page);
const providerHeight = (await defaultsDialog.boundingBox())?.height ?? 0;
await provider.click();
await page.getByTestId("global-agent-provider-option-anthropic").click();
await expect(
defaultsDialog.getByTestId("global-agent-model"),
).toBeVisible();
for (const field of [
harness,
provider,
defaultsDialog.getByTestId("global-agent-model"),
defaultsDialog.getByTestId("global-agent-thinking-effort-select"),
]) {
await expect(field).toHaveClass(/h-11/);
await expect(field).toHaveClass(/rounded-xl/);
await expect(field).toHaveClass(/bg-muted\/40/);
await expect(field).toHaveClass(/shadow-none/);
}
await waitForAnimations(page);
const configuredHeight = (await defaultsDialog.boundingBox())?.height ?? 0;
expect(configuredHeight).toBeGreaterThan(providerHeight);
await expect(
defaultsDialog.getByTestId("global-agent-config-fields"),
).not.toHaveClass(/bg-muted\/20/);
const harnessBox = await defaultsDialog
.getByTestId("global-agent-default-harness")
.boundingBox();
const providerBox = await defaultsDialog
.getByTestId("global-agent-provider")
.boundingBox();
expect(harnessBox?.x).toBe(providerBox?.x);
expect(harnessBox?.width).toBe(providerBox?.width);
await waitForAnimations(page);
await defaultsDialog.screenshot({
path: `${SHOTS}/04-global-defaults-dialog-flat.png`,
});
const advanced = defaultsDialog.getByTestId("global-agent-advanced-toggle");
await provider.click();
await page
.getByTestId("global-agent-provider-option-databricks_v2")
.click();
await expect(advanced).toHaveAttribute("aria-expanded", "false");
const saveDefaults = defaultsDialog.getByRole("button", {
name: "Save defaults",
});
await expect(
defaultsDialog.getByTestId("global-agent-advanced-required-badge"),
).toHaveText("Required");
await expect(saveDefaults).toBeDisabled();
await advanced.click();
await expect(advanced).toHaveAttribute("aria-expanded", "true");
await defaultsDialog
.getByLabel("Value for DATABRICKS_HOST")
.fill("https://databricks.example.test");
await expect(saveDefaults).toBeEnabled();
await defaultsDialog
.getByRole("button", {
name: "Close",
})
.click();
await page.getByRole("button", { name: "Discard changes" }).click();
await expect(defaultsDialog).not.toBeVisible();
await waitForAnimations(page);
@@ -406,6 +562,166 @@ test.describe("global agent config screenshots", () => {
});
});
test("unset defaults persist the visible Buzz Agent fallback", async ({
page,
}) => {
await installMockBridge(page);
await openCreateDialog(page);
await page
.getByTestId("agent-ai-defaults-notice")
.getByRole("button", { name: "Set" })
.click();
const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog");
await expect(
defaultsDialog.getByTestId("global-agent-default-harness"),
).toHaveText("Buzz Agent");
await defaultsDialog.getByTestId("global-agent-provider").click();
await page.getByTestId("global-agent-provider-option-anthropic").click();
await defaultsDialog.getByLabel("Anthropic API Key").fill("sk-ant-test");
await expect(
defaultsDialog.getByRole("button", { name: "Save defaults" }),
).toBeEnabled();
await defaultsDialog.getByRole("button", { name: "Save defaults" }).click();
await expect(defaultsDialog).not.toBeVisible();
const saved = await page.evaluate(async () =>
(
window as typeof window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload: unknown,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null),
);
expect(saved).toMatchObject({
preferred_runtime: "buzz-agent",
provider: "anthropic",
});
});
test("create defaults follow a preferred harness saved while open", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: CATALOG_WITH_CLAUDE,
globalAgentConfig: {
preferred_runtime: "buzz-agent",
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-global-value" },
},
});
await openCreateDialog(page);
const defaults = page.getByTestId("agent-ai-defaults-notice");
await expect(defaults).toContainText("Buzz Agent");
await defaults
.getByRole("button", { name: "Edit global defaults" })
.click();
const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog");
const harness = defaultsDialog.getByTestId("global-agent-default-harness");
await harness.press("Enter");
await page
.getByTestId("global-agent-default-harness-option-claude")
.click();
await defaultsDialog.getByRole("button", { name: "Save defaults" }).click();
await expect(defaultsDialog).not.toBeVisible();
const harnessDefaults = page.getByTestId("agent-harness-defaults-notice");
await expect(harnessDefaults).toContainText("Claude Code");
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled();
await page.getByTestId("persona-dialog-submit").click();
await expect
.poll(() =>
page.evaluate(() => {
const log = (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { input?: Record<string, unknown> };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__;
const createPayload = log?.find(
(entry) => entry.command === "create_persona",
)?.payload.input;
return createPayload?.runtime;
}),
)
.toBe("claude");
});
test("missing global credentials show the unset defaults notice", async ({
page,
}) => {
await installMockBridge(page, {
globalAgentConfig: {
preferred_runtime: "buzz-agent",
provider: "anthropic",
model: "claude-opus-4-5",
env_vars: {},
},
});
await openCreateDialog(page);
const defaults = page.getByTestId("agent-ai-defaults-notice");
await expect(defaults).toContainText("Global defaults not set");
await expect(defaults.getByRole("button", { name: "Set" })).toBeVisible();
await expect(defaults.getByText("Harness", { exact: true })).toHaveCount(0);
await expect(defaults.getByText("Provider", { exact: true })).toHaveCount(
0,
);
await expect(defaults.getByText("Model", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled();
});
test("create exposes setup guidance when no harness is available", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: CATALOG_NONE_AVAILABLE,
});
await openCreateDialog(page);
const customSection = page.getByTestId(
"agent-custom-configuration-section",
);
const harness = customSection.locator("#persona-runtime");
await expect(harness).toBeVisible();
await expect(harness).toContainText("Choose a harness");
await selectDropdownOption(page, harness, "Buzz Agent (not installed)");
await expect(
customSection
.locator("p")
.filter({ hasText: "Buzz Agent is not installed." }),
).toContainText(
"Buzz Agent is not installed. Visit Settings > Agents to set it up.",
);
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled();
});
test("create with a missing name has no footer message", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create from scratch" }).click();
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({
timeout: 10_000,
});
await expect(page.getByTestId("persona-dialog-submit-reason")).toHaveCount(
0,
);
});
// Shot 05: Create gate ENABLED — global provider = anthropic provides a
// default, so the empty per-agent provider is resolved → submit enabled.
test("05-create-enabled-with-global-provider", async ({ page }) => {
@@ -419,6 +735,20 @@ test.describe("global agent config screenshots", () => {
await openCreateDialog(page);
const defaultsSection = page.getByTestId(
"agent-defaults-configuration-section",
);
await expect(defaultsSection.locator("#persona-runtime")).toHaveCount(0);
await expect(
defaultsSection.getByTestId("agent-ai-defaults-notice"),
).toBeVisible();
await expect(
defaultsSection.getByText("Harness", { exact: true }),
).toBeVisible();
await expect(
defaultsSection.getByText("Buzz Agent", { exact: true }),
).toBeVisible();
// Global provider satisfies the provider-default rule → submit enabled.
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({
timeout: 10_000,
@@ -449,12 +779,14 @@ test.describe("global agent config screenshots", () => {
await openCreateDialog(page);
// Switch the auto-selected buzz-agent runtime to the CLI-login runtime.
// Harness selection belongs to the per-agent customization flow.
await customizeAgentAi(page);
await selectDropdownOption(
page,
page.locator("#persona-runtime"),
"Claude Code",
);
await page.getByRole("tab", { name: "Use harness defaults" }).click();
// Provider picker hidden — the runtime drives its own provider.
await expect(page.locator("#persona-llm-provider")).not.toBeVisible();
@@ -636,12 +968,10 @@ test.describe("global agent config screenshots", () => {
await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({
timeout: 10_000,
});
// … and the reason must be the Customize-pair provider gate, not the
// global-defaults gate (which would say "Settings → AI defaults").
const reason = page.getByTestId("persona-dialog-submit-reason");
await expect(reason).toBeVisible({ timeout: 10_000 });
await expect(reason).toContainText("Select a provider");
await expect(reason).not.toContainText("Settings → AI defaults");
// Disabled-state guidance belongs with the fields, not in the modal footer.
await expect(page.getByTestId("persona-dialog-submit-reason")).toHaveCount(
0,
);
await waitForAnimations(page);
+9
View File
@@ -397,6 +397,15 @@ type MockBridgeOptions = {
model: string | null;
preferred_runtime?: string | null;
};
/** File-layer config returned by runtime id. */
runtimeFileConfigs?: Record<
string,
{
provider: string | null;
model: string | null;
satisfiedEnvKeys: string[];
} | null
>;
bakedBuildEnv?: Array<{
key: string;
masked: boolean;