diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index da230bdf45..dc61984487 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -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 diff --git a/desktop/src/features/agents/ui/AdvancedRequiredBadge.tsx b/desktop/src/features/agents/ui/AdvancedRequiredBadge.tsx new file mode 100644 index 0000000000..a85903d884 --- /dev/null +++ b/desktop/src/features/agents/ui/AdvancedRequiredBadge.tsx @@ -0,0 +1,26 @@ +import { hasMissingRequiredEnvKey } from "./personaRuntimeModel"; + +export function AdvancedRequiredBadge({ + envVars, + requiredEnvKeys, + show, + testId, +}: { + envVars?: Record; + requiredEnvKeys?: readonly string[]; + show?: boolean; + testId: string; +}) { + const visible = + show ?? hasMissingRequiredEnvKey(requiredEnvKeys ?? [], envVars ?? {}); + if (!visible) return null; + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx index 92f18a2163..6750649021 100644 --- a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx +++ b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx @@ -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 ( -
- Model{" "} - +
+
Harness
+
+ {harness || "Not configured"} +
+
Model
+
{model?.trim() || "Harness default"} - -
+ + + ); +} + +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; +}) { + return canChooseProvider ? ( + + ) : ( + ); } @@ -36,22 +83,31 @@ export function AgentAiConfigurationModeField({ } value={mode} > - - + + ); } diff --git a/desktop/src/features/agents/ui/AgentAiDefaults.tsx b/desktop/src/features/agents/ui/AgentAiDefaults.tsx index 1f77f883f7..a577113a0a 100644 --- a/desktop/src/features/agents/ui/AgentAiDefaults.tsx +++ b/desktop/src/features/agents/ui/AgentAiDefaults.tsx @@ -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; 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 ( +
+

+ Global defaults not set +

+ +
+ ); + } + return (
+ {harness !== undefined ? ( + <> +
Harness
+
+ {harness || "Not configured"} +
+ + ) : null}
Provider
{provider ? providerLabel(provider) : "Not configured"} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 4e5ba17607..1bd8af8976 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -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({ ); - const content = ( - <> - {providerFieldVisible ? ( -
- - {!useCustomSelect && useChevronSelectIcon ? ( -
- {providerSelect} -
- ) : ( - providerSelect - )} - {isCustomProvider ? ( - handleCustomProviderInput(e.target.value)} - placeholder="Custom provider ID" - value={providerValue} - /> - ) : null} + const providerContent = providerFieldVisible ? ( +
+ + {!useCustomSelect && useChevronSelectIcon ? ( +
+ {providerSelect} +
+ ) : ( + providerSelect + )} + {isCustomProvider ? ( + handleCustomProviderInput(e.target.value)} + placeholder="Custom provider ID" + usePersonaInputStyle={progressiveDefaults} + value={providerValue} + /> ) : null} +
+ ) : null; + const dependentContent = ( + <> {providerFieldVisible && apiKeyEnvVar ? (
) : null} @@ -817,12 +871,19 @@ export function AgentConfigFields({
- {advancedOpen ? ( + {disclosure === "progressive-defaults" ? ( + + {advancedOpen ? ( + + k !== BUZZ_AGENT_THINKING_EFFORT, + ), + )} + /> + + ) : null} + + ) : advancedOpen ? ( ); + const content = ( + <> + {providerContent} + {disclosure === "progressive-defaults" ? ( + + {revealDependentFields ? ( + + {dependentContent} + + ) : null} + + ) : ( + dependentContent + )} + + ); + if (unstyled) { - return
{content}
; + return ( +
+ {content} +
+ ); } - return {content}; + return ( + + {content} + + ); } diff --git a/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx b/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx index 7eedae4112..0c05fe99a5 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx @@ -94,6 +94,7 @@ export function AgentDefaultsDialog({ svg]:text-muted-foreground/60", +); + export type GlobalAgentConfigSaveResult = Awaited< ReturnType >; 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(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 ? ( + + ) : null; + const progressiveFieldsTransition = shouldReduceMotion + ? { duration: 0 } + : PROGRESSIVE_FIELDS_TRANSITION; + return ( -
+
{configSurfaceLoading ? (
@@ -224,26 +282,37 @@ export function AgentDefaultsEditor({ Default harness
- + {flatLayout ? ( + + {configFields ? ( + + {configFields} + + ) : null} + + ) : ( + configFields + )} )} @@ -269,7 +338,12 @@ export function AgentDefaultsEditor({
{secondaryAction} - -
+
+ +
} > @@ -836,24 +829,6 @@ export function AgentDefinitionDialog({
-
- - - {runtimeWarning} -
- {modelFieldVisible ? ( ) : null} - {llmProviderFieldVisible && aiConfigurationMode === "custom" ? ( -
- - LLM provider - {!providerIsRequired ? ( - - Optional - - ) : null} - - + {aiConfigurationMode === "custom" ? ( + - {showCustomProviderInput ? ( -
+ - + Optional + + ) : null} + + + {showCustomProviderInput ? ( +
setProvider(event.target.value)} - placeholder="Custom provider ID" - value={provider} - /> -
- ) : null} -
- ) : null} - - {llmProviderFieldVisible && - aiConfigurationMode === "custom" && - topLevelSecretEnvVar ? ( - { - setEnvVars((prev) => ({ - ...prev, - [topLevelSecretEnvVar]: next, - })); - }} - value={apiKeyValue} - /> - ) : null} + > + setProvider(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> +
+ ) : null} +
+ ) : null} - - {modelFieldVisible && aiConfigurationMode === "custom" ? ( - { + setEnvVars((prev) => ({ + ...prev, + [topLevelSecretEnvVar]: next, + })); + }} + value={apiKeyValue} /> ) : null} - - {aiConfigurationMode === "defaults" ? ( - runtimeCanChooseLlmProvider ? ( - setAiDefaultsOpen(true)} - triggerRef={aiDefaultsTriggerRef} - explicitModel="" - explicitProvider="" + + {modelFieldVisible && aiConfigurationMode === "custom" ? ( + + ) : null} + + + {aiConfigurationMode === "defaults" ? ( + setAiDefaultsOpen(true)} + triggerRef={aiDefaultsTriggerRef} /> - ) : ( - - ) - ) : null} + ) : null} +
Advanced + {localModeGate.missingEnvKeys.some((key) => + advancedRequiredEnvKeys.includes(key), + ) ? ( + + ) : null} void; + options: PersonaDropdownOption[]; + placeholder: string; + value: string; + warning?: ReactNode; +}) { + return ( +
+ + + {warning} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index c2cc5de18b..5cd5c7a016 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -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({

) : null}
+ {selectedRuntimeId === "custom" && !inheritHarness ? ( +
+ +
+ setAgentCommand(event.target.value)} + placeholder="Full path or shell command" + value={agentCommand} + /> +
+
+ ) : null} {/* LLM provider */} {llmProviderFieldVisible ? (
@@ -1074,15 +1090,11 @@ export function AgentInstanceEditDialog({ />
) : null} -

- {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."} -

+ {modelStatusMessage ? ( +

+ {modelStatusMessage} +

+ ) : null} Advanced + } + className="mx-auto w-full max-w-[996px]" description="Set up and manage your agents." title="Agents" /> diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index b1d79495e1..35de0d983d 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -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({

- {/* Custom agent command (when custom runtime) */} - {selectedRuntimeId === "custom" && !inheritHarness ? ( -
- -
- onAgentCommandChange(event.target.value)} - placeholder="Full path or shell command" - value={agentCommand} - /> -
-
- ) : null} - {/* Agent runtime args */}
); diff --git a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs index 2435130f6d..7bb3888b34 100644 --- a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs @@ -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. diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 8b1003e951..6b797db1cf 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -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. diff --git a/desktop/src/features/agents/ui/globalAgentCredentialState.test.mjs b/desktop/src/features/agents/ui/globalAgentCredentialState.test.mjs new file mode 100644 index 0000000000..99a0374957 --- /dev/null +++ b/desktop/src/features/agents/ui/globalAgentCredentialState.test.mjs @@ -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); +}); diff --git a/desktop/src/features/agents/ui/globalAgentCredentialState.ts b/desktop/src/features/agents/ui/globalAgentCredentialState.ts new file mode 100644 index 0000000000..a8bc25e076 --- /dev/null +++ b/desktop/src/features/agents/ui/globalAgentCredentialState.ts @@ -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; + 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, + }; +} diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index d8da4108c9..d138296b5c 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -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, ): boolean { return requiredEnvKeys.some((key) => (envVars[key] ?? "").length === 0); diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs b/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs deleted file mode 100644 index 4984a1d554..0000000000 --- a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs +++ /dev/null @@ -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, - ); -}); diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.ts b/desktop/src/features/agents/ui/personaSubmitBlock.ts deleted file mode 100644 index 8ee7d9f428..0000000000 --- a/desktop/src/features/agents/ui/personaSubmitBlock.ts +++ /dev/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."; - } - - // 2–4. 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; -} diff --git a/desktop/src/features/agents/ui/providerApiKeyFieldState.ts b/desktop/src/features/agents/ui/providerApiKeyFieldState.ts index 13ed31101a..0aabc3d798 100644 --- a/desktop/src/features/agents/ui/providerApiKeyFieldState.ts +++ b/desktop/src/features/agents/ui/providerApiKeyFieldState.ts @@ -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>; }): 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; } diff --git a/desktop/src/features/agents/ui/useRequiredCredentialState.ts b/desktop/src/features/agents/ui/useRequiredCredentialState.ts index c996e159ca..44c03e812a 100644 --- a/desktop/src/features/agents/ui/useRequiredCredentialState.ts +++ b/desktop/src/features/agents/ui/useRequiredCredentialState.ts @@ -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: bakedEnvKeys, isLoading: bakedLoading } = - useBakedBuildEnvKeysQuery({ enabled: open }); + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + prospectiveRuntimeId, + { 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, }; } diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index e25d95e75e..90f8b22d62 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -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 diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 721082aedb..9ad4059a05 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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; /** 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. diff --git a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts index 6200975889..de94bb230f 100644 --- a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts +++ b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts @@ -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, }); diff --git a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts index 085e202da9..cb63361d51 100644 --- a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts +++ b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts @@ -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); }); }); diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts index 856cc67422..8f4c7d1478 100644 --- a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts +++ b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts @@ -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, }); diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index 4b4036e66c..95ef231556 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -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, diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 59d6759e3d..d9b3214e64 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -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; + } + ).__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 }; + }>; + } + ).__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); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 66b5c89705..aeb2e4a7be 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -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;