Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions desktop/src/features/agents/ui/AgentConfigFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ import {
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CARD_MINT_KEY_ANNOTATIONS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getPersonaProviderOptions,
getProviderApiKeyEnvVar,
getProviderApiKeyLabel,
runtimeSupportsLlmProviderSelection,
} from "@/features/agents/ui/agentConfigOptions";
import {
Expand All @@ -54,6 +56,7 @@ import {
} from "@/features/agents/ui/buzzAgentModelTuningFields";
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
import { CardMintKeyCue } from "./CardMintKeyCue";
import { getGlobalAgentCredentialState } from "./globalAgentCredentialState";

export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
Expand All @@ -74,7 +77,6 @@ const PROGRESSIVE_FIELDS_TRANSITION = {
duration: 0.22,
ease: [0.23, 1, 0.32, 1],
} as const;

type AgentConfigDisclosure =
| "full"
| "onboarding-essential"
Expand All @@ -85,13 +87,9 @@ type AgentConfigDisclosure =
// - auto-select a valid model when the provider changes
// - keep the model select usable during discovery
// - preserve credential env vars across provider switches (the abandoned
// provider's key stays in env_vars — visible/deletable under Advanced —
// so flipping back never loses a typed key; spawned agents may therefore
// see credentials for providers they don't use)
// provider's key stays in env_vars — visible/deletable under Advanced)
// - require a provider before model/effort are editable (no saveable
// invalid state — design principle #4). Note: legacy configs saved with
// a model but no provider are cleared by the pre-existing orphan-model
// effect on next edit — deliberate data healing, documented in PR.
// invalid state — design principle #4)
const autoSelectModelOnProviderChange = true;
const disableModelSelectDuringDiscovery = false;
const preserveCredentialEnvVarsOnProviderChange = true;
Expand Down Expand Up @@ -747,18 +745,15 @@ export function AgentConfigFields({
<div className={blockClassName}>
<PersonaProviderApiKeyField
disabled={false}
envVarName={apiKeyEnvVar}
inheritedLabel={
apiKeyFileSatisfied
? "Set in runtime config"
: "Provided by this build"
}
isInherited={apiKeyInherited}
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
label={
effectiveProvider === "anthropic"
? "Anthropic API Key"
: "OpenAI API Key"
}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
onValueChange={(value) =>
onConfigChange({
...config,
Expand Down Expand Up @@ -869,6 +864,7 @@ export function AgentConfigFields({

{showAdvancedFields ? (
<div className={cn(blockClassName, "space-y-3")}>
<CardMintKeyCue envVars={config.env_vars} />
<button
aria-expanded={advancedOpen}
className={cn(
Expand Down Expand Up @@ -912,6 +908,7 @@ export function AgentConfigFields({
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
Expand All @@ -930,6 +927,7 @@ export function AgentConfigFields({
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
label="Environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
Expand Down
8 changes: 3 additions & 5 deletions desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
getDefaultPersonaRuntime,
getPersonaModelOptions,
getPersonaProviderOptions,
getProviderApiKeyLabel,
getRuntimePersonaModelOptions,
NO_RUNTIME_DROPDOWN_VALUE,
runtimeSupportsLlmProviderSelection,
Expand Down Expand Up @@ -907,14 +908,11 @@ export function AgentDefinitionDialog({
topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isPending}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={
effectiveProvider === "anthropic"
? "Anthropic API key"
: "OpenAI API key"
}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API key"}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
Expand Down
12 changes: 6 additions & 6 deletions desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ import {
getBakedModelInheritLabel,
getBakedProviderInheritLabel,
} from "./bakedEnvHelpers";
import { getProviderApiKeyEnvVar } from "./agentConfigOptions";
import {
getProviderApiKeyEnvVar,
getProviderApiKeyLabel,
} from "./agentConfigOptions";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
Expand Down Expand Up @@ -1061,14 +1064,11 @@ export function AgentInstanceEditDialog({
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={updateMutation.isPending}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={
effectiveProvider === "anthropic"
? "Anthropic API Key"
: "OpenAI API Key"
}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
Expand Down
29 changes: 29 additions & 0 deletions desktop/src/features/agents/ui/CardMintKeyCue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Always-visible cue shown in Agent Defaults when a global `OPENAI_API_KEY`
* row exists (nonblank). The Advanced section is collapsed by default, so
* the `keyAnnotations` hint on that row is invisible until the user opens it
* — this cue bridges the gap by surfacing the information at the decision
* point.
*
* Renders nothing when `OPENAI_API_KEY` is absent or blank.
*/
export function CardMintKeyCue({
envVars,
}: {
envVars: Record<string, string>;
}) {
const isSet =
"OPENAI_API_KEY" in envVars &&
(envVars.OPENAI_API_KEY ?? "").trim().length > 0;
if (!isSet) return null;

return (
<p
className="text-xs text-muted-foreground"
data-testid="card-mint-key-cue"
>
Card-minting key <span className="font-mono">OPENAI_API_KEY</span> is set
under Advanced → Environment variables.
</p>
);
}
2 changes: 2 additions & 0 deletions desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import {
CARD_MINT_KEY_ANNOTATIONS,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
Expand Down Expand Up @@ -247,6 +248,7 @@ export function EditAgentAdvancedFields({
helperText="Per-agent env vars. Override the template's vars on collision."
inheritedFrom={inheritedEnvVars}
inheritedLabel="template / global defaults"
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
onChange={onEnvVarsChange}
requiredKeys={requiredEnvKeys}
value={envVars}
Expand Down
94 changes: 94 additions & 0 deletions desktop/src/features/agents/ui/EnvVarsEditor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,97 @@ test("getBakedProviderInheritLabel_empty_options_falls_back_to_raw_id", () => {
"empty options table must fall back to raw id",
);
});

// ── keyAnnotations — annotation lookup invariants ─────────────────────────────
//
// `keyAnnotations` is a pass-through prop: the renderer does `keyAnnotations?.[key]`.
// The invariant worth pinning is that the prop contract is respected at the
// data level — an annotation for one key does NOT bleed into another key.
// (Rendering itself is trivially conditional; no logic to extract.)

test("keyAnnotations_present_key_has_annotation", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
assert.equal(
annotations.OPENAI_API_KEY,
"Used for minting agent trading cards",
);
});

test("keyAnnotations_absent_key_is_undefined", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
assert.equal(annotations.ANTHROPIC_API_KEY, undefined);
});

test("keyAnnotations_empty_map_has_no_annotations", () => {
const annotations = {};
assert.equal(annotations.OPENAI_API_KEY, undefined);
});

test("keyAnnotations_only_matching_key_gets_annotation", () => {
// Verifies the per-key lookup is not accidentally global.
const annotations = { OPENAI_API_KEY: "card minting" };
const keys = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "FOO"];
const results = keys.map((k) => annotations[k] ?? null);
assert.deepEqual(results, ["card minting", null, null]);
});

// ── keyAnnotations render — annotation appears only on matching row ─────────
//
// renderToStaticMarkup exercises the real JSX path:
// {keyAnnotations?.[row.key] ? <p ...>{annotation}</p> : null}
// This confirms the prop is plumbed through to the DOM correctly and that
// annotation text is scoped to its matching row.

import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { EnvVarsEditor } from "./EnvVarsEditor.tsx";

test("keyAnnotations_annotation_present_only_on_matching_row", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
const html = renderToStaticMarkup(
React.createElement(EnvVarsEditor, {
disabled: false,
fileSatisfiedKeys: [],
hiddenKeys: [],
keyAnnotations: annotations,
onChange: () => {},
requiredKeys: [],
value: { OPENAI_API_KEY: "sk-placeholder", ANTHROPIC_API_KEY: "sk-ant" },
}),
);
assert.ok(
html.includes("Used for minting agent trading cards"),
"annotation must appear in rendered output for OPENAI_API_KEY row",
);
// The annotation must not bleed to other rows — check that it appears only once.
const count = (html.match(/Used for minting agent trading cards/g) ?? [])
.length;
assert.equal(count, 1, "annotation must appear exactly once");
});

test("keyAnnotations_annotation_absent_for_non_matching_rows", () => {
const annotations = {
OPENAI_API_KEY: "Used for minting agent trading cards",
};
const html = renderToStaticMarkup(
React.createElement(EnvVarsEditor, {
disabled: false,
fileSatisfiedKeys: [],
hiddenKeys: [],
keyAnnotations: annotations,
onChange: () => {},
requiredKeys: [],
value: { ANTHROPIC_API_KEY: "sk-ant", MY_VAR: "foo" },
}),
);
assert.ok(
!html.includes("Used for minting agent trading cards"),
"annotation must not appear when its key is not in the env map",
);
});
25 changes: 25 additions & 0 deletions desktop/src/features/agents/ui/EnvVarsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ type EnvVarsEditorProps = {
inheritedRows?: readonly InheritedEnvRow[];
/** Label for the inherited-row tag (e.g. "build"). Defaults to "build". */
inheritedRowsLabel?: string;
/**
* Optional muted one-line annotation for specific env var keys. Rendered
* below any row whose key appears in this map — required rows, user rows,
* and user rows whose key is typed mid-edit. Intended for contextual hints
* like `{ OPENAI_API_KEY: "Used for minting agent trading cards" }` that
* help users distinguish two keys with similar names.
*/
keyAnnotations?: Readonly<Record<string, string>>;
};

type Row = { id: string; key: string; value: string };
Expand All @@ -191,6 +199,7 @@ export function EnvVarsEditor({
focusKey,
inheritedRows = [],
inheritedRowsLabel = "build",
keyAnnotations,
}: EnvVarsEditorProps) {
// Keys that render as their own special rows (required amber rows or
// file-satisfied read-only rows). These must NEVER enter `rows` state —
Expand Down Expand Up @@ -406,6 +415,14 @@ export function EnvVarsEditor({
</p>
);
})()}
{keyAnnotations?.[key] ? (
<p
className="ml-1 text-xs text-muted-foreground"
data-testid="env-vars-key-annotation"
>
{keyAnnotations[key]}
</p>
) : null}
</div>
);
})}
Expand Down Expand Up @@ -596,6 +613,14 @@ export function EnvVarsEditor({
</p>
);
})()}
{row.key.length > 0 && keyAnnotations?.[row.key] ? (
<p
className="ml-1 text-xs text-muted-foreground"
data-testid="env-vars-key-annotation"
>
{keyAnnotations[row.key]}
</p>
) : null}
</div>
);
})}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/agents/ui/PersonaAdvancedFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PersonaBehaviorDraft } from "./personaBehaviorDraft";
import { isBuzzAgentRuntime } from "./buzzAgentConfig";
import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields";
import {
CARD_MINT_KEY_ANNOTATIONS,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
Expand Down Expand Up @@ -142,6 +143,7 @@ export function PersonaAdvancedFields({
disabled={disabled}
fileSatisfiedKeys={fileSatisfiedEnvKeys}
hiddenKeys={hiddenEnvKeys}
keyAnnotations={CARD_MINT_KEY_ANNOTATIONS}
onChange={onEnvVarsChange}
requiredKeys={requiredEnvKeys}
value={envVars}
Expand Down
Loading
Loading