mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Clarify agent harness defaults in create flow (#2601)
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user