@@ -1027,14 +1061,11 @@ export function AgentInstanceEditDialog({
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
{
setEnvVars((prev) => ({
...prev,
@@ -1166,6 +1197,8 @@ export function AgentInstanceEditDialog({
parallelism={parallelism}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
+ catalogStatus={runtimeCatalogStatus}
+ selectedRuntime={prospectiveRuntime}
systemPrompt={systemPrompt}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx
index 604bf9f703..27541889f2 100644
--- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx
+++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx
@@ -1,6 +1,6 @@
import { useAgentManagement } from "@/features/agents/useAgentManagement";
+import { AgentCardDialogs } from "./AgentCardViewerDialog";
import { AgentDialog } from "./AgentDialog";
-import { SecretRevealDialog } from "./SecretRevealDialog";
/** Global review surfaces opened by owned agents through the Buzz harness. */
export function AgentManagementDialogs() {
@@ -21,20 +21,7 @@ export function AgentManagementDialogs() {
}}
onSubmitDefinition={management.submitCreate}
runtimes={management.runtimes}
- runtimesLoading={management.runtimesLoading}
- />
- ) : null}
- {management.createdAgent ? (
- {
- if (!open) management.dismissCreatedAgent();
- }}
- onRetryAttachment={() => {
- void management.retryAttachment();
- }}
+ runtimeCatalogStatus={management.runtimeCatalogStatus}
/>
) : null}
{management.request?.action === "update" ? (
@@ -50,11 +37,12 @@ export function AgentManagementDialogs() {
onSubmit={management.submitUpdate}
open
runtimes={management.runtimes}
- runtimesLoading={management.runtimesLoading}
+ runtimeCatalogStatus={management.runtimeCatalogStatus}
submitLabel="Save changes"
title="Edit agent"
/>
) : null}
+
>
);
}
diff --git a/desktop/src/features/agents/ui/AgentRunLocationContext.tsx b/desktop/src/features/agents/ui/AgentRunLocationContext.tsx
new file mode 100644
index 0000000000..5815130b47
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentRunLocationContext.tsx
@@ -0,0 +1,43 @@
+import * as React from "react";
+
+import type { AgentRunLocation } from "../lib/agentAccessWarning";
+
+/**
+ * Where the agent a dialog subtree is editing will run.
+ *
+ * Context rather than a prop on purpose: the only consumer is the respond-to
+ * warning, buried several levels inside `AgentDefinitionDialog` (1000+ lines)
+ * and `AgentInstanceEditDialog` (1200+ lines). Threading a prop through them
+ * would grow two files that are already over the 1000-line ceiling enforced by
+ * `desktop/scripts/check-file-sizes.mjs`, for a value neither of them uses.
+ *
+ * `AgentDialog` is the single entry point for all three dialog modes, so it is
+ * the one place that provides this. Surfaces that render the respond-to field
+ * outside that tree (e.g. `EditRespondToDialog`) pass the `runLocation` prop
+ * directly instead.
+ *
+ * The default is `null` — unknown. Never provide a guessed value; the copy
+ * falls back to the local wording, which is correct for any owner who has not
+ * installed a `buzz-backend-*` provider.
+ */
+const AgentRunLocationContext = React.createContext(
+ null,
+);
+
+export function AgentRunLocationProvider({
+ children,
+ runLocation,
+}: {
+ children: React.ReactNode;
+ runLocation: AgentRunLocation | null;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAgentRunLocation(): AgentRunLocation | null {
+ return React.useContext(AgentRunLocationContext);
+}
diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
index 6f34ffba79..1b3c7a0574 100644
--- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
+++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx
@@ -1,7 +1,6 @@
-import { CircleAlert, Play } from "lucide-react";
+import { CircleAlert } from "lucide-react";
import { useReducedMotion } from "motion/react";
-import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import {
type AvatarBadgeCurve,
MaskedAvatarBadgeFrame,
@@ -18,8 +17,10 @@ type AgentRuntimeAvatarControlProps = {
errorLabel?: string | null;
errorTestId?: string;
isActive: boolean;
+ isRestarting?: boolean;
isStarting: boolean;
label: string;
+ requiresRestart?: boolean;
startTestId: string;
onOpenError?: () => void;
onStart: () => void;
@@ -29,6 +30,7 @@ const TAILWIND_SPACING = {
"1": 4,
"2": 8,
"2.5": 10,
+ "3.5": 14,
"6": 24,
"11": 44,
"24": 96,
@@ -36,44 +38,58 @@ const TAILWIND_SPACING = {
const AGENT_AVATAR_SIZE = TAILWIND_SPACING["24"];
const ACTION_BADGE_SIZE = TAILWIND_SPACING["11"];
-const ACTIVE_BADGE_SIZE = TAILWIND_SPACING["6"];
-const ACTION_BADGE_OFFSET = TAILWIND_SPACING["2.5"];
+const ACTION_BUTTON_HEIGHT = 36;
+const START_ACTION_BADGE_WIDTH = 56;
+const RESTART_ACTION_BADGE_WIDTH = 72;
+const ACTIVE_BADGE_CUTOUT_SIZE = TAILWIND_SPACING["6"];
+const ACTIVE_DOT_SIZE = 18;
+const ACTION_BADGE_OFFSET = TAILWIND_SPACING["3.5"] + TAILWIND_SPACING["1"];
const ACTIVE_BADGE_INSET = TAILWIND_SPACING["1"];
-const ACTIVE_DOT_CLASS_NAME = "h-4.5 w-4.5";
const PROFILE_STATUS_CUTOUT_RATIO = 1.25;
function getBadgeCenter(badgeSize: number, outwardOffset: number) {
return AGENT_AVATAR_SIZE + outwardOffset - badgeSize / 2;
}
-function getActionBadge(offset: number) {
+function getActionBadge(width: number, height: number, offset: number) {
+ const centerY = getBadgeCenter(ACTION_BADGE_SIZE, offset);
+ const clearance = (ACTION_BADGE_SIZE - height) / 2;
+
return {
cutout: {
- cx: getBadgeCenter(ACTION_BADGE_SIZE, offset),
- cy: getBadgeCenter(ACTION_BADGE_SIZE, offset),
+ // Keep the cutout on the avatar edge so the mask has the same soft,
+ // two-point join as the status dot. Unlike the status dot, center the
+ // primary action horizontally to make its purpose easier to spot.
+ cx: AGENT_AVATAR_SIZE / 2,
+ cy: centerY,
r: ACTION_BADGE_SIZE / 2,
},
shell: {
- bottom: -offset,
- height: ACTION_BADGE_SIZE,
- right: -offset,
- width: ACTION_BADGE_SIZE,
+ bottom: AGENT_AVATAR_SIZE - centerY - height / 2,
+ height,
+ right: (AGENT_AVATAR_SIZE - width) / 2,
+ width,
},
+ // Carry the vertical clearance around the end caps horizontally too, so
+ // the avatar gap stays even around the pill.
+ cutoutWidth: width + clearance * 2,
} as const;
}
function getActiveBadge(inset: number) {
+ const center = getBadgeCenter(ACTIVE_BADGE_CUTOUT_SIZE, -inset);
+
return {
cutout: {
- cx: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset),
- cy: getBadgeCenter(ACTIVE_BADGE_SIZE, -inset),
- r: (ACTIVE_BADGE_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO,
+ cx: center,
+ cy: center,
+ r: (ACTIVE_BADGE_CUTOUT_SIZE / 2) * PROFILE_STATUS_CUTOUT_RATIO,
},
shell: {
- bottom: inset,
- height: ACTIVE_BADGE_SIZE,
- right: inset,
- width: ACTIVE_BADGE_SIZE,
+ bottom: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2,
+ height: ACTIVE_DOT_SIZE,
+ right: AGENT_AVATAR_SIZE - center - ACTIVE_DOT_SIZE / 2,
+ width: ACTIVE_DOT_SIZE,
},
} as const;
}
@@ -87,12 +103,26 @@ const ACTION_MASK_CURVE = {
handleLengthRatio: 0.26,
} satisfies AvatarBadgeCurve;
-const ACTION_BADGE = getActionBadge(ACTION_BADGE_OFFSET);
+const START_ACTION_BADGE = getActionBadge(
+ START_ACTION_BADGE_WIDTH,
+ ACTION_BUTTON_HEIGHT,
+ ACTION_BADGE_OFFSET,
+);
+const RESTART_ACTION_BADGE = getActionBadge(
+ RESTART_ACTION_BADGE_WIDTH,
+ ACTION_BUTTON_HEIGHT,
+ ACTION_BADGE_OFFSET,
+);
+const ERROR_BADGE = getActionBadge(
+ ACTION_BADGE_SIZE,
+ ACTION_BUTTON_HEIGHT,
+ ACTION_BADGE_OFFSET,
+);
const ACTIVE_BADGE = getActiveBadge(ACTIVE_BADGE_INSET);
const MASK_TRANSITION = {
- duration: 0.22,
- ease: [0.23, 1, 0.32, 1],
+ duration: 0.3,
+ ease: [0.4, 0, 0.2, 1],
} as const;
export function AgentRuntimeAvatarControl({
@@ -101,45 +131,66 @@ export function AgentRuntimeAvatarControl({
errorLabel,
errorTestId,
isActive,
+ isRestarting = false,
isStarting,
label,
+ requiresRestart = false,
startTestId,
onOpenError,
onStart,
}: AgentRuntimeAvatarControlProps) {
const shouldReduceMotion = useReducedMotion();
const trimmedAvatarUrl = avatarUrl?.trim() || null;
- const actionLabel = isStarting ? `Starting ${label}` : `Start ${label}`;
- const hasError = !isActive && !isStarting && Boolean(errorLabel);
+ const isRestartAction = requiresRestart || isRestarting;
+ const actionLabel = isRestarting
+ ? "Restarting Agent"
+ : isStarting
+ ? "Starting Agent"
+ : isRestartAction
+ ? "Restart Agent"
+ : "Start Agent";
+ const actionText = isRestartAction ? "Restart" : "Start";
+ const isPending = isStarting || isRestarting;
+ const showRunningDot = isActive && !isRestartAction;
+ const hasError = !isActive && !isPending && Boolean(errorLabel);
const errorActionLabel = `${label} has a runtime error. Open runtime details.`;
const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION;
- const badge = isActive ? ACTIVE_BADGE : ACTION_BADGE;
+ const actionBadge = isRestartAction
+ ? RESTART_ACTION_BADGE
+ : START_ACTION_BADGE;
+ const badge = showRunningDot
+ ? ACTIVE_BADGE
+ : hasError
+ ? ERROR_BADGE
+ : actionBadge;
+ const actionCutoutWidth =
+ showRunningDot || hasError ? undefined : actionBadge.cutoutWidth;
return (
- {isActive ? (
+ {showRunningDot ? (
-
-
+ />
) : (
)}
}
badgeBox={badge.shell}
+ badgeClassName={cn(
+ "transition-colors ease-in-out",
+ shouldReduceMotion ? "duration-0" : "duration-300",
+ showRunningDot
+ ? "bg-emerald-500"
+ : hasError
+ ? "bg-destructive"
+ : isRestartAction
+ ? "bg-amber-500/15"
+ : "bg-primary",
+ )}
className="h-24 w-24"
- curve={isActive ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE}
+ curve={showRunningDot ? STATUS_DOT_MASK_CURVE : ACTION_MASK_CURVE}
cutout={badge.cutout}
+ cutoutWidth={actionCutoutWidth}
maskTransition={transition}
size={AGENT_AVATAR_SIZE}
>
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 29bfd7dbab..d24c8fab79 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -747,7 +747,7 @@ function PromptContextDialog({
return (
{/* Relay URL: intentionally no editor. The legacy per-record relay pin
@@ -211,45 +297,68 @@ export function EditAgentAdvancedFields({
- {/* System prompt override */}
-
-
-
-
+ )}
{/* Env vars */}
- {/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */}
+ {/* Descriptor-driven numeric tuning knobs — shown when the catalog has settled
+ and the runtime exposes numeric env-var fields. */}
+ {numericDescriptors.length > 0 ? (
+ {
+ const next = { ...envVars };
+ if (value === "") {
+ delete next[key];
+ } else {
+ next[key] = value;
+ }
+ onEnvVarsChange(next);
+ }}
+ />
+ ) : null}
+
+ {/* Effort-tuning knob — only shown for buzz-agent. */}
{isBuzzAgentRuntime(modelTuningRuntimeId) ? (
{
"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] ?
{annotation}
: 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",
+ );
+});
+
+// ── buildRecord with hiddenKeys: structured-field preservation ────────────
+//
+// These tests exercise the exported buildRecord(nextRows, value, requiredKeys,
+// hiddenKeys) using the real implementation. hiddenKeys are structured-field
+// env vars (e.g. BUZZ_AGENT_MAX_ROUNDS) that are owned by first-class controls
+// outside the editor — they must survive onChange cycles even though they
+// never appear as generic rows.
+//
+// Four scenarios from rev 4:
+// 1. Edit an unrelated generic row → hidden (tuning) key is unchanged.
+// 2. Runtime switch then generic edit: after switching to a new runtime,
+// the new runtime's hidden keys survive; old runtime keys appear as
+// generic rows and survive via toRecord, not hidden-key preservation.
+// 3. Baked numeric key excluded via real descriptor/helper path: uses the
+// production deriveAgentConfigFieldModel + structuredEnvKeys helpers to
+// derive the hidden set, then verifies toRows excludes the numeric key.
+// 4. Clearing a structured override → placeholder returns: after the user
+// clears a structured field (key absent from value), buildRecord must
+// not reintroduce it, leaving the structured field free to show the
+// Inherit placeholder.
+
+test("buildRecord_hidden_tuning_key_unchanged_when_generic_row_edited", () => {
+ // Structured field set BUZZ_AGENT_MAX_ROUNDS to "50"; it lives in value
+ // as a hiddenKey. User then edits a generic env var via the row editor.
+ // The tuning key must survive the buildRecord emit cycle unchanged.
+ const value = { BUZZ_AGENT_MAX_ROUNDS: "50", MY_VAR: "old" };
+ const nextRows = [{ id: "r1", key: "MY_VAR", value: "new" }];
+ const record = buildRecordUtil(
+ nextRows,
+ value,
+ [],
+ ["BUZZ_AGENT_MAX_ROUNDS"],
+ );
+
+ assert.equal(
+ record.BUZZ_AGENT_MAX_ROUNDS,
+ "50",
+ "hidden tuning key must survive when an unrelated generic row is edited",
+ );
+ assert.equal(record.MY_VAR, "new", "generic row edit applied");
+});
+
+test("buildRecord_runtime_switch_new_hiddenKeys_then_generic_edit", () => {
+ // Scenario 2: runtime switch then generic edit.
+ //
+ // Before switch: agent is buzz-agent with BUZZ_AGENT_MAX_ROUNDS = "50" stored
+ // in value (set via the numeric tuning control). After switching to Goose,
+ // the buzz-agent key is no longer hidden — it becomes a visible generic row.
+ // The test verifies:
+ // (a) After the switch, the old buzz-agent key appears as a generic row
+ // (toRows with the new Goose hidden set projects it).
+ // (b) After a generic-row edit, buildRecord preserves BOTH the old-runtime
+ // key (now a generic row) and the new-runtime hidden key.
+ // (c) An unset new-runtime hidden key is not introduced.
+
+ // Derive both descriptor sets from real runtime objects.
+ const buzzAgentRuntime = {
+ id: "buzz-agent",
+ label: "Buzz Agent",
+ avatarUrl: "",
+ availability: "available",
+ command: "buzz-agent",
+ binaryPath: "buzz-agent",
+ defaultArgs: [],
+ mcpCommand: null,
+ modelEnvVar: "BUZZ_AGENT_MODEL",
+ providerEnvVar: "BUZZ_AGENT_PROVIDER",
+ thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
+ maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
+ contextLimitEnvVar: "BUZZ_AGENT_CONTEXT_LIMIT",
+ maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS",
+ installHint: "",
+ installInstructionsUrl: "",
+ canAutoInstall: false,
+ underlyingCliPath: null,
+ nodeRequired: false,
+ authStatus: { status: "not_applicable" },
+ loginHint: null,
+ };
+ const gooseRuntime = {
+ id: "goose",
+ label: "Goose",
+ avatarUrl: "",
+ availability: "available",
+ command: "goose",
+ binaryPath: "goose",
+ defaultArgs: ["acp"],
+ mcpCommand: null,
+ modelEnvVar: null,
+ providerEnvVar: null,
+ thinkingEnvVar: null,
+ maxTokensEnvVar: "GOOSE_MAX_TOKENS",
+ contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT",
+ maxRoundsEnvVar: null,
+ installHint: "",
+ installInstructionsUrl: "",
+ canAutoInstall: true,
+ underlyingCliPath: null,
+ nodeRequired: false,
+ authStatus: { status: "not_applicable" },
+ loginHint: null,
+ };
+
+ const buzzDescriptors = deriveNumericDescriptors(buzzAgentRuntime);
+ const gooseDescriptors = deriveNumericDescriptors(gooseRuntime);
+ const buzzHiddenKeys = structuredEnvKeys(buzzDescriptors);
+ const gooseHiddenKeys = structuredEnvKeys(gooseDescriptors);
+
+ // Sanity-check that BUZZ_AGENT_MAX_ROUNDS is hidden under buzz-agent but not
+ // under Goose — that contrast is what makes it become a generic row.
+ assert.ok(
+ buzzHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"),
+ "BUZZ_AGENT_MAX_ROUNDS must be hidden under buzz-agent descriptors",
+ );
+ assert.equal(
+ gooseHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"),
+ false,
+ "BUZZ_AGENT_MAX_ROUNDS must not be hidden under Goose descriptors",
+ );
+
+ // Pre-switch value: buzz-agent max-rounds was set, GOOSE_MAX_TOKENS was
+ // already set (e.g. user configured it before switching back), plus a
+ // generic user var. GOOSE_MAX_TOKENS is a hidden key under the Goose
+ // descriptor set, so it must survive buildRecord() via hiddenKeys.
+ const valueBeforeSwitch = {
+ BUZZ_AGENT_MAX_ROUNDS: "50",
+ GOOSE_MAX_TOKENS: "16384",
+ USER_VAR: "original",
+ };
+
+ // After the switch to Goose, toRows is reproj with the new (Goose) hidden
+ // set. BUZZ_AGENT_MAX_ROUNDS is no longer hidden → appears as a generic row.
+ // GOOSE_MAX_TOKENS IS hidden under Goose → must not appear in generic rows.
+ const rowsAfterSwitch = toRows(valueBeforeSwitch, new Set(gooseHiddenKeys));
+ assert.ok(
+ rowsAfterSwitch.some((r) => r.key === "BUZZ_AGENT_MAX_ROUNDS"),
+ "old-runtime key must become a generic row after the switch",
+ );
+ assert.equal(
+ rowsAfterSwitch.some((r) => r.key === "GOOSE_MAX_TOKENS"),
+ false,
+ "new-runtime hidden key must not appear as a generic row after the switch",
+ );
+
+ // User edits the generic USER_VAR row.
+ const editedRows = rowsAfterSwitch.map((r) =>
+ r.key === "USER_VAR" ? { ...r, value: "updated" } : r,
+ );
+
+ // buildRecord: old-runtime key survives via toRecord (it's now a generic
+ // row); new-runtime Goose hidden key survives via hiddenKeys (carried
+ // through from value). An unset Goose key must not be introduced.
+ const record = buildRecordUtil(
+ editedRows,
+ valueBeforeSwitch,
+ [],
+ gooseHiddenKeys,
+ );
+
+ assert.equal(
+ record.BUZZ_AGENT_MAX_ROUNDS,
+ "50",
+ "old-runtime key must survive as a generic row value after switch",
+ );
+ assert.equal(
+ record.GOOSE_MAX_TOKENS,
+ "16384",
+ "new-runtime hidden key must survive buildRecord via hiddenKeys",
+ );
+ assert.equal(record.USER_VAR, "updated", "generic row edit applied");
+ assert.equal(
+ "GOOSE_CONTEXT_LIMIT" in record,
+ false,
+ "unset new-runtime hidden key must not be introduced",
+ );
+});
+
+test("filterBakedGenericRows_numeric_baked_key_excluded_and_placeholder_shown", () => {
+ // Scenario 3: baked numeric key excluded via the real production helper.
+ //
+ // The global baked env contains BUZZ_AGENT_MAX_OUTPUT_TOKENS = "4096"
+ // (the baked value shipped with the agent). The production
+ // filterBakedGenericRows path must exclude this key from the generic
+ // baked-row display so it isn't editable twice, while the structured
+ // numeric input shows the inherited placeholder via numericTuningPlaceholder.
+ const buzzAgentRuntime = {
+ id: "buzz-agent",
+ label: "Buzz Agent",
+ avatarUrl: "",
+ availability: "available",
+ command: "buzz-agent",
+ binaryPath: "buzz-agent",
+ defaultArgs: [],
+ mcpCommand: null,
+ modelEnvVar: "BUZZ_AGENT_MODEL",
+ providerEnvVar: "BUZZ_AGENT_PROVIDER",
+ thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
+ maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS",
+ contextLimitEnvVar: null,
+ maxRoundsEnvVar: null,
+ installHint: "",
+ installInstructionsUrl: "",
+ canAutoInstall: false,
+ underlyingCliPath: null,
+ nodeRequired: false,
+ authStatus: { status: "not_applicable" },
+ loginHint: null,
+ };
+
+ const numericDescriptors = deriveNumericDescriptors(buzzAgentRuntime);
+ const numericStructuredKeys = structuredEnvKeys(numericDescriptors);
+
+ assert.ok(
+ numericStructuredKeys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
+ "numeric key must appear in structured keys via production helpers",
+ );
+
+ // Simulate the baked env: BUZZ_AGENT_MAX_OUTPUT_TOKENS is baked, plus a
+ // non-structured baked var.
+ const bakedEnv = [
+ { key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", value: "4096" },
+ { key: "SOME_OTHER_BAKED_VAR", value: "hello" },
+ ];
+
+ // filterBakedGenericRows must exclude the numeric key.
+ const genericRows = filterBakedGenericRows(bakedEnv, numericStructuredKeys);
+
+ assert.equal(
+ genericRows.some((r) => r.key === "BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
+ false,
+ "baked numeric key must be excluded from generic baked rows",
+ );
+ assert.ok(
+ genericRows.some((r) => r.key === "SOME_OTHER_BAKED_VAR"),
+ "non-structured baked var must remain in generic rows",
+ );
+
+ // The structured numeric input shows the inherited placeholder for the
+ // baked value via numericTuningPlaceholder.
+ const bakedValue = "4096";
+ assert.equal(
+ numericTuningPlaceholder(bakedValue),
+ "Inherit (4096)",
+ "structured placeholder must reflect the baked value",
+ );
+ assert.equal(
+ numericTuningPlaceholder(undefined),
+ "Inherit (agent default)",
+ "structured placeholder without baked value shows agent-default text",
+ );
+});
+
+test("buildRecord_clearing_structured_field_allows_placeholder_to_return", () => {
+ // Scenario 4: clearing a structured override → placeholder returns.
+ //
+ // Step 1: value has BUZZ_AGENT_MAX_ROUNDS = "50" (user set it via the
+ // structured field). BUZZ_AGENT_MAX_ROUNDS is in hiddenKeys.
+ // Step 2: user clears the structured field → onEnvVarChange(key, "")
+ // removes the key from value (value no longer contains it).
+ // Step 3: after the clear, buildRecord must not reintroduce the key.
+ // Step 4: with the key absent from value, numericTuningPlaceholder over
+ // the (now-empty) inheritedEnvVars shows "Inherit (agent default)"
+ // — the numeric field's empty-state placeholder.
+
+ // After the clear, value no longer contains BUZZ_AGENT_MAX_ROUNDS.
+ const valueAfterClear = { MY_VAR: "foo" };
+ const nextRows = [{ id: "r1", key: "MY_VAR", value: "updated" }];
+
+ const record = buildRecordUtil(
+ nextRows,
+ valueAfterClear,
+ [],
+ ["BUZZ_AGENT_MAX_ROUNDS"],
+ );
+
+ assert.equal(
+ "BUZZ_AGENT_MAX_ROUNDS" in record,
+ false,
+ "cleared structured key must not be reintroduced by buildRecord",
+ );
+ assert.equal(record.MY_VAR, "updated");
+
+ // With the key cleared, the inherited value is also absent (not set
+ // globally). numericTuningPlaceholder returns the agent-default text —
+ // the placeholder that renders in the structured input.
+ const inheritedAfterClear = undefined;
+ assert.equal(
+ numericTuningPlaceholder(inheritedAfterClear),
+ "Inherit (agent default)",
+ "numeric input must show Inherit (agent default) after clear when no global override",
+ );
+
+ // If a global override IS set, the placeholder shows that value instead.
+ const inheritedGlobal = "25";
+ assert.equal(
+ numericTuningPlaceholder(inheritedGlobal),
+ "Inherit (25)",
+ "numeric input must show Inherit () when a global override exists",
+ );
+});
diff --git a/desktop/src/features/agents/ui/EnvVarsEditor.tsx b/desktop/src/features/agents/ui/EnvVarsEditor.tsx
index 18bbf68337..08496eece5 100644
--- a/desktop/src/features/agents/ui/EnvVarsEditor.tsx
+++ b/desktop/src/features/agents/ui/EnvVarsEditor.tsx
@@ -44,7 +44,9 @@ export function toRows(
* Collapse an ordered row list back to a record, skipping rows with empty
* keys. Exported for unit tests.
*/
-export function toRecord(rows: Row[]): EnvVarsValue {
+export function toRecord(
+ rows: readonly { key: string; value: string }[],
+): EnvVarsValue {
const out: EnvVarsValue = {};
for (const row of rows) {
// Empty key = user is mid-edit; skip it so we don't poison the record.
@@ -165,10 +167,39 @@ 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>;
};
type Row = { id: string; key: string; value: string };
+/**
+ * Pure record builder: merges `toRecord(nextRows)` with the current values of
+ * `requiredKeys` and `hiddenKeys` from `value`. Required and hidden keys are
+ * excluded from the row state (`skipKeys`), so this merge is the only place
+ * their current values survive an `onChange` emit cycle.
+ *
+ * Exported for unit testing. `EnvVarsEditor` calls this internally.
+ */
+export function buildRecord(
+ nextRows: readonly { key: string; value: string }[],
+ value: EnvVarsValue,
+ requiredKeys: readonly string[],
+ hiddenKeys: readonly string[],
+): EnvVarsValue {
+ const base: EnvVarsValue = {};
+ for (const key of [...requiredKeys, ...hiddenKeys]) {
+ if (key in value) base[key] = value[key];
+ }
+ return { ...base, ...toRecord(nextRows) };
+}
+
/**
* A flat key/value editor for environment variables.
*
@@ -191,6 +222,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 —
@@ -232,18 +264,6 @@ export function EnvVarsEditor({
}
}, [value, skipKeys]);
- // Build the emitted record: normal rows + required-key values preserved
- // from `value`. Required keys are never in `rows`, so `toRecord(rows)`
- // would silently drop any required secret the user just typed unless we
- // merge them back explicitly.
- function buildRecord(nextRows: Row[]): EnvVarsValue {
- const base: EnvVarsValue = {};
- for (const key of [...requiredKeys, ...hiddenKeys]) {
- if (key in value) base[key] = value[key];
- }
- return { ...base, ...toRecord(nextRows) };
- }
-
// Ref map: key → required-value Input element. Populated via callback refs
// on each required-key row's value Input so focus can be dispatched directly
// without any DOM walking through presentation classes.
@@ -285,7 +305,7 @@ export function EnvVarsEditor({
function emit(next: Row[]) {
setRows(next);
- const record = buildRecord(next);
+ const record = buildRecord(next, value, requiredKeys, hiddenKeys);
lastEmitted.current = record;
onChange(record);
}
@@ -406,6 +426,14 @@ export function EnvVarsEditor({
);
})()}
+ {keyAnnotations?.[key] ? (
+
);
})}
diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
index f39d96fd26..62a4169fc9 100644
--- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
@@ -1,11 +1,6 @@
import * as React from "react";
-import {
- AlertTriangle,
- ChevronDown,
- ChevronRight,
- RefreshCw,
-} from "lucide-react";
+import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
@@ -27,6 +22,7 @@ import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastE
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { PubKey } from "@/shared/ui/PubKey";
import { SubsectionLabel } from "@/shared/ui/PageHeader";
+import { RestartDiffBadge } from "./RestartDiffBadge";
export function ManagedAgentRow({
agent,
@@ -100,7 +96,7 @@ export function ManagedAgentRow({
"overflow-hidden transition-colors",
isLogSelected ? "bg-primary/5" : "hover:bg-muted/20",
)}
- data-testid={`managed-agent-${agent.pubkey}`}
+ data-testid={`managed-agent-row-${agent.pubkey}`}
>
{isLocal ? (
@@ -158,7 +154,16 @@ export function ManagedAgentRow({
)}
+ {/* B4: restart badge is a sibling of the expansion button — never
+ inside it. TooltipTrigger renders as a (non-interactive),
+ so no nested interactive elements are introduced here. */}
- Buzz will choose an available shared model when the agent starts.
+ Auto uses Mesh collective intelligence when two or more models stay
+ available, otherwise it chooses one available model.
) : null}
{modelDiscoveryStatus ? (
diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.test.mjs b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.test.mjs
new file mode 100644
index 0000000000..62a6ff416c
--- /dev/null
+++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.test.mjs
@@ -0,0 +1,170 @@
+/**
+ * Behavioral tests for PersonaProviderApiKeyField.
+ *
+ * Tests the rendering invariants that matter for the disambiguation story:
+ * - semantic label is present in the rendered output
+ * - envVarName hint is rendered when the prop is present
+ * - hint id is wired to the input via aria-describedby
+ * - hint is absent when envVarName is omitted
+ * - two simultaneous instances produce unique IDs (no duplicate-ID collision
+ * in the nested AgentInstanceEditDialog + AgentDefaultsDialog path)
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField.tsx";
+
+function makeProps(overrides = {}) {
+ return {
+ disabled: false,
+ isInherited: false,
+ inheritedLabel: "Set in global defaults",
+ isRequired: false,
+ label: "OpenAI Runtime API Key",
+ onValueChange: () => {},
+ value: "",
+ ...overrides,
+ };
+}
+
+/** Extract the value of the first attribute matching `name="..."` in html. */
+function extractAttr(html, attrName) {
+ const re = new RegExp(`${attrName}="([^"]+)"`);
+ const m = re.exec(html);
+ return m ? m[1] : null;
+}
+
+/** Extract ALL values of an attribute from html, in document order. */
+function extractAllAttrs(html, attrName) {
+ const re = new RegExp(`${attrName}="([^"]+)"`, "g");
+ return Array.from(html.matchAll(re), (m) => m[1]);
+}
+
+test("PersonaProviderApiKeyField_renders_semantic_label", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(PersonaProviderApiKeyField, makeProps()),
+ );
+ assert.ok(
+ html.includes("OpenAI Runtime API Key"),
+ "semantic label must appear in rendered output",
+ );
+});
+
+test("PersonaProviderApiKeyField_renders_env_var_hint_when_envVarName_present", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(
+ PersonaProviderApiKeyField,
+ makeProps({ envVarName: "OPENAI_COMPAT_API_KEY" }),
+ ),
+ );
+ assert.ok(
+ html.includes("OPENAI_COMPAT_API_KEY"),
+ "env-var hint must appear when envVarName is provided",
+ );
+});
+
+test("PersonaProviderApiKeyField_wires_hint_id_via_aria_describedby", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(
+ PersonaProviderApiKeyField,
+ makeProps({ envVarName: "OPENAI_COMPAT_API_KEY" }),
+ ),
+ );
+ // Extract the dynamically-generated hint id from the rendered paragraph.
+ const hintId = extractAttr(html, "id");
+ assert.ok(hintId, "hint paragraph must have an id");
+ assert.ok(
+ hintId.startsWith("persona-provider-api-key-hint-"),
+ `hint id must follow the expected prefix, got: ${hintId}`,
+ );
+ // The input's aria-describedby must point at the same id.
+ const describedBy = extractAttr(html, "aria-describedby");
+ assert.equal(
+ describedBy,
+ hintId,
+ "input aria-describedby must reference the hint's id",
+ );
+});
+
+test("PersonaProviderApiKeyField_omits_hint_when_envVarName_absent", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(PersonaProviderApiKeyField, makeProps()),
+ );
+ assert.ok(
+ !html.includes("aria-describedby"),
+ "no aria-describedby when envVarName is omitted",
+ );
+ assert.ok(
+ !html.includes("persona-provider-api-key-hint"),
+ "hint id must not appear when envVarName is omitted",
+ );
+});
+
+test("PersonaProviderApiKeyField_two_instances_have_unique_ids_and_each_aria_describedby_resolves_to_own_hint", () => {
+ // Render BOTH instances in a single renderToStaticMarkup call — this
+ // mirrors the real nested-dialog DOM where AgentInstanceEditDialog's
+ // credential field and the nested AgentDefaultsDialog's field are
+ // simultaneously mounted under the same React root. A shared root is what
+ // makes React.useId() guarantee uniqueness; two separate renderToStaticMarkup
+ // calls each reset the counter and would produce the same ID.
+ const combined = renderToStaticMarkup(
+ React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(
+ PersonaProviderApiKeyField,
+ makeProps({
+ label: "Anthropic API Key",
+ envVarName: "ANTHROPIC_API_KEY",
+ }),
+ ),
+ React.createElement(
+ PersonaProviderApiKeyField,
+ makeProps({
+ label: "OpenAI Runtime API Key",
+ envVarName: "OPENAI_COMPAT_API_KEY",
+ }),
+ ),
+ ),
+ );
+
+ // Two hint paragraph ids must be present and distinct.
+ const allHintIds = extractAllAttrs(combined, "id").filter((id) =>
+ id.startsWith("persona-provider-api-key-hint-"),
+ );
+ assert.equal(allHintIds.length, 2, "exactly two hint ids must be present");
+ const [hintIdA, hintIdB] = allHintIds;
+ assert.notEqual(hintIdA, hintIdB, "two instances must not share a hint id");
+
+ // Each input's aria-describedby must match its own hint id (same order).
+ const allDescribedBy = extractAllAttrs(combined, "aria-describedby");
+ assert.equal(
+ allDescribedBy.length,
+ 2,
+ "exactly two aria-describedby attributes must be present",
+ );
+ assert.equal(
+ allDescribedBy[0],
+ hintIdA,
+ "instance A: aria-describedby must reference instance A's own hint",
+ );
+ assert.equal(
+ allDescribedBy[1],
+ hintIdB,
+ "instance B: aria-describedby must reference instance B's own hint",
+ );
+
+ // Confirm each instance names its own env var in the rendered output.
+ assert.ok(
+ combined.includes("ANTHROPIC_API_KEY"),
+ "combined output must name ANTHROPIC_API_KEY",
+ );
+ assert.ok(
+ combined.includes("OPENAI_COMPAT_API_KEY"),
+ "combined output must name OPENAI_COMPAT_API_KEY",
+ );
+});
diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx
index 17f9e2e826..2be1f1c28d 100644
--- a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx
+++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx
@@ -25,6 +25,7 @@ import {
*/
export function PersonaProviderApiKeyField({
disabled,
+ envVarName,
isInherited,
inheritedLabel,
isRequired,
@@ -33,6 +34,13 @@ export function PersonaProviderApiKeyField({
value,
}: {
disabled: boolean;
+ /**
+ * The backing environment variable name, e.g. `OPENAI_COMPAT_API_KEY`.
+ * Rendered as a monospace hint beneath the label so users can distinguish
+ * this field from other keys with similar names (e.g. `OPENAI_API_KEY`).
+ * When present, the input's `aria-describedby` points at the hint element.
+ */
+ envVarName?: string;
/** True when the key is satisfied by an inherited layer. */
isInherited: boolean;
/** Human-readable source of the inherited value. */
@@ -46,13 +54,22 @@ export function PersonaProviderApiKeyField({
value: string;
}) {
const [showValue, setShowValue] = React.useState(false);
- const inputId = "persona-provider-api-key";
+ const uid = React.useId();
+ const inputId = `persona-provider-api-key-${uid}`;
+ const hintId = envVarName
+ ? `persona-provider-api-key-hint-${uid}`
+ : undefined;
return (
- ) : null}
-
- );
-}
diff --git a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
index ee800b5cb5..4b04de638d 100644
--- a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
+++ b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
@@ -6,7 +6,6 @@ import {
type OpenCreateAgentOptions,
} from "@/features/agents/openCreateAgentEvent";
import { AgentDialog } from "./AgentDialog";
-import { SecretRevealDialog } from "./SecretRevealDialog";
import { usePersonaActions } from "./usePersonaActions";
/** App-level create flow so contextual entry points do not navigate away. */
@@ -55,20 +54,13 @@ export function RequestedAgentCreateDialogs() {
personas.handleSubmit(input, intent, backendIntent, targetChannel)
}
runtimes={personas.acpRuntimesQuery.data ?? []}
- runtimesLoading={personas.acpRuntimesQuery.isLoading}
- />
- ) : null}
- {personas.createdAgent ? (
- {
- if (!open) personas.dismissCreatedAgent();
- }}
- onRetryAttachment={() => {
- void personas.retryAttachment();
- }}
+ runtimeCatalogStatus={
+ personas.acpRuntimesQuery.isLoading
+ ? "loading"
+ : personas.acpRuntimesQuery.isError
+ ? "error"
+ : "ready"
+ }
/>
) : null}
>
diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx
index d9773ad681..589d4c8c7a 100644
--- a/desktop/src/features/agents/ui/RespondToField.tsx
+++ b/desktop/src/features/agents/ui/RespondToField.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { ChevronDown, Search, X } from "lucide-react";
+import { AlertTriangle, ChevronDown, Search, X } from "lucide-react";
import {
mergeAllowlist,
parsePubkeyInput,
@@ -13,6 +13,11 @@ import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { UserAvatar } from "@/shared/ui/UserAvatar";
+import {
+ type AgentRunLocation,
+ agentAccessWarningText,
+} from "@/features/agents/lib/agentAccessWarning";
+import { useAgentRunLocation } from "./AgentRunLocationContext";
import { PersonaDropdownField } from "./PersonaDropdownField";
import type { PersonaDropdownOption } from "./agentConfigOptions";
@@ -20,14 +25,31 @@ import type { PersonaDropdownOption } from "./agentConfigOptions";
* Inbound author gate UI for create/edit agent dialogs.
*
* Dropdown:
- * - Owner only (default; matches `buzz-acp --respond-to=owner-only`)
- * - Anyone (`--respond-to=anyone` — fully open bot)
- * - Allowlist (`--respond-to=allowlist`, plus the chip list as
- * `--respond-to-allowlist`)
+ * - Only me (default; maps to `buzz-acp --respond-to=owner-only`)
+ * - Anyone (`--respond-to=anyone` — fully open agent)
+ * - Selected people (`--respond-to=allowlist`, plus the selected pubkeys as
+ * `--respond-to-allowlist`)
*
* `nobody` is intentionally not surfaced — it pairs with a heartbeat-only
* setup that has no meaningful GUI use case.
*
+ * Anyone and Selected people both share the host's access with someone other
+ * than the owner, so both render the persistent warning; only the audience
+ * phrase differs. It leads with the audience so it reads as a warning rather
+ * than an explanation, and stays one sentence — Only me already owns the line
+ * below the control.
+ *
+ * The line below Only me says "Only you and your agents", because the harness
+ * gate admits the owner and every verified same-owner agent, not the owner
+ * alone (see `managed_agents/access_policy.rs`). The dropdown label stays
+ * "Only me": it is the audience the user picks, and it has meant this since
+ * before agents could instruct each other.
+ *
+ * Which machine and stakes it names follow the optional `runLocation` prop, and
+ * an unknown location falls back to the local wording rather than hedging with
+ * "computer or server" — see `lib/agentAccessWarning.ts` for the copy and the
+ * reasoning.
+ *
* Validation is duplicated lightly here for inline UX feedback only; the
* authoritative validator is `validate_respond_to_allowlist` in
* `desktop/src-tauri/src/managed_agents/types.rs`.
@@ -53,9 +75,12 @@ function formatSearchUserSecondary(user: UserSearchResult) {
const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
{ label: "Only me (default)", value: "owner-only" },
{ label: "Anyone", value: "anyone" },
- { label: "Allowlist", value: "allowlist" },
+ { label: "Selected people", value: "allowlist" },
];
+export const OWNER_ONLY_ACCESS_DISABLED_REASON =
+ "This build disallows changing this setting.";
+
export function CreateAgentRespondToField({
mode,
allowlist,
@@ -63,7 +88,9 @@ export function CreateAgentRespondToField({
onAllowlistChange,
ownerPubkey,
disabled,
+ disabledReason,
variant,
+ runLocation,
}: {
mode: RespondToMode;
allowlist: string[];
@@ -76,8 +103,16 @@ export function CreateAgentRespondToField({
*/
ownerPubkey?: string | null;
disabled?: boolean;
+ /** Explanation shown when this access control is unavailable. */
+ disabledReason?: string;
/** When "persona", uses PersonaDropdownField styling to match the persona dialog. */
variant?: "default" | "persona";
+ /**
+ * Where the agent's process runs, when the surface can tell. Omit or pass
+ * `null` when it can't — the warning then uses the same "your computer"
+ * wording as a local agent rather than hedging. Never synthesize a value.
+ */
+ runLocation?: AgentRunLocation | null;
}) {
const [query, setQuery] = React.useState("");
const [isDirectEntryOpen, setIsDirectEntryOpen] = React.useState(false);
@@ -132,6 +167,33 @@ export function CreateAgentRespondToField({
const isPersonaVariant = variant === "persona";
+ // An explicit prop wins; otherwise inherit from the dialog subtree. Surfaces
+ // inside AgentDialog get it from context (see AgentRunLocationContext for
+ // why), standalone ones like EditRespondToDialog pass the prop.
+ const inheritedRunLocation = useAgentRunLocation();
+ const warningText = agentAccessWarningText(
+ mode,
+ runLocation ?? inheritedRunLocation,
+ );
+
+ // Rendered in two positions: directly below the selector for Anyone, but
+ // after the people picker for Selected people, so it never sits between the
+ // user and the selection they came here to make.
+ const accessWarning = warningText ? (
+
- Controls which Nostr authors the agent listens to (@mentions, DMs,
- thread replies). The agent's owner can always shut it down with
- !shutdown.
+ Only you and your agents can send instructions.
- Owner (
- ) is always implicitly allowed by the
- harness — no need to add it here.
+ You (
+ ) can always use this agent. You
+ don't need to add yourself.
) : !isPersona ? (
- The agent's owner is always implicitly allowed.
+ You can always use this agent.
) : null}
@@ -452,7 +524,7 @@ function AllowlistPicker({
onClick={onAddFromPaste}
type="button"
>
- Add to allowlist
+ Add people
diff --git a/desktop/src/features/agents/ui/RestartDiffBadge.tsx b/desktop/src/features/agents/ui/RestartDiffBadge.tsx
new file mode 100644
index 0000000000..1bdb781226
--- /dev/null
+++ b/desktop/src/features/agents/ui/RestartDiffBadge.tsx
@@ -0,0 +1,209 @@
+import { RefreshCw } from "lucide-react";
+
+import { Badge } from "@/shared/ui/badge";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+import type { RestartDiffEntry, RestartChange } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+
+// ── Auto-restart copy ─────────────────────────────────────────────────────────
+
+/**
+ * Shared blurb strings for the auto-restart state. Exported so the Runtime-tab
+ * banner can import the same constants — the two surfaces must never drift.
+ */
+export const AUTO_RESTART_ON_BLURB =
+ "Configuration changed since this agent started. Buzz can restart it automatically after ~3 minutes idle, or stop and respawn it to apply now.";
+
+export const AUTO_RESTART_OFF_BLURB =
+ "Configuration changed since this agent started. Automatic restart is off for this agent — stop and respawn it to apply the changes.";
+
+// ── Label helpers ─────────────────────────────────────────────────────────────
+
+/**
+ * Humanise a serde dotted-path field id into a display label.
+ * `snake_case` → `"Snake case"`, `env.FOO` → `"FOO (env)"`.
+ * Unknown paths render as-is — no per-field label map.
+ */
+function humaniseFieldId(field: string): string {
+ if (field.startsWith("env.")) {
+ const key = field.slice(4);
+ return `${key} (env)`;
+ }
+ // snake_case → words, sentence case
+ return field.replace(/_/g, " ").replace(/\b\w/, (c) => c.toUpperCase());
+}
+
+// ── Change description ────────────────────────────────────────────────────────
+
+function formatJsonValue(v: unknown): string {
+ if (v === null) return "null";
+ if (v === undefined) return "—";
+ if (typeof v === "string") return v;
+ return JSON.stringify(v);
+}
+
+function ChangeDescription({ change }: { change: RestartChange }) {
+ switch (change.kind) {
+ case "value":
+ return (
+
+
+ {formatJsonValue(change.before)}
+
+ {" → "}
+ {formatJsonValue(change.after)}
+
+ );
+ case "text": {
+ const before = change.before_chars ?? 0;
+ const after = change.after_chars ?? 0;
+ return (
+
+ {before} chars → {after} chars
+
+ );
+ }
+ case "masked":
+ return (
+
+
+ {change.before ?? "••••"}
+
+ {" → "}
+ {change.after ?? "••••"}
+
+ );
+ case "added":
+ return added;
+ case "removed":
+ return removed;
+ default:
+ // Unknown kind — render nothing, remain type-safe at runtime
+ return null;
+ }
+}
+
+// ── Diff list ─────────────────────────────────────────────────────────────────
+
+const TOOLTIP_CAP = 6;
+
+/**
+ * `tooltip` — renders inside the dark `bg-primary` tooltip; uses
+ * `text-primary-foreground` variants for contrast there.
+ * `inline` — renders inside the amber Runtime banner or other light
+ * surfaces; inherits foreground from the container instead.
+ */
+function DiffList({
+ entries,
+ cap,
+ variant = "tooltip",
+}: {
+ entries: RestartDiffEntry[];
+ cap?: number;
+ variant?: "tooltip" | "inline";
+}) {
+ const visible = cap !== undefined ? entries.slice(0, cap) : entries;
+ const overflow =
+ cap !== undefined && entries.length > cap ? entries.length - cap : 0;
+
+ const valueClass =
+ variant === "tooltip" ? "text-primary-foreground/80" : "text-foreground";
+ const overflowClass =
+ variant === "tooltip"
+ ? "text-primary-foreground/60"
+ : "text-muted-foreground";
+
+ return (
+
+ {visible.map((entry) => (
+
+
+ {humaniseFieldId(entry.field)}:
+
+
+
+
+
+ ))}
+ {overflow > 0 ? (
+
and {overflow} more
+ ) : null}
+
+ );
+}
+
+// ── Badge + tooltip ───────────────────────────────────────────────────────────
+
+/**
+ * The restart-required badge. When `restartDiff` is non-empty, shows a hover
+ * tooltip with the itemised before→after diff (capped at {@link TOOLTIP_CAP}
+ * entries) plus the auto-restart blurb. Renders as a non-interactive ``
+ * so it can safely be placed adjacent to (not inside) a `` — it must
+ * never be a descendant of an interactive element.
+ */
+export function RestartDiffBadge({
+ autoRestartEnabled = false,
+ restartDiff,
+ className,
+}: {
+ autoRestartEnabled?: boolean;
+ restartDiff: RestartDiffEntry[];
+ className?: string;
+}) {
+ const badge = (
+
+
+ Restart required
+
+ );
+
+ if (restartDiff.length === 0) {
+ // No diff data — render plain badge without tooltip.
+ return badge;
+ }
+
+ return (
+
+ {/* asChild renders the trigger as the Badge's , which is a valid
+ non-interactive element. No nested button. */}
+
+
+
+ Restart required
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Full uncapped diff list for the Runtime-tab banner. Renders inline; no
+ * tooltip wrapper.
+ */
+export function RestartDiffList({
+ restartDiff,
+}: {
+ restartDiff: RestartDiffEntry[];
+}) {
+ if (restartDiff.length === 0) return null;
+ return (
+
+
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/RunOnSummarySection.tsx b/desktop/src/features/agents/ui/RunOnSummarySection.tsx
new file mode 100644
index 0000000000..d3d2f30381
--- /dev/null
+++ b/desktop/src/features/agents/ui/RunOnSummarySection.tsx
@@ -0,0 +1,73 @@
+import type { ManagedAgentBackend } from "@/shared/api/types";
+
+import { summarizeRunOn } from "./runOnSummary";
+
+/**
+ * Read-only "Run on" summary for the edit-agent dialog.
+ *
+ * Read-only on purpose: `UpdateManagedAgentRequest` has no backend field —
+ * where an agent runs is fixed at creation (a provider-backed agent's
+ * deployment holds its private key; there is no migrate operation). This
+ * section shows the *saved* provider config from the record, without probing
+ * the provider binary: an edit dialog must not do executable work as a side
+ * effect, and a live probe would show today's schema defaults instead of
+ * what this agent actually deployed with.
+ *
+ * Named "Run on" (matching the create flow) rather than "Provider" because
+ * this dialog already uses "Provider" for the ACP harness selector.
+ */
+export function RunOnSummarySection({
+ backend,
+}: {
+ backend: ManagedAgentBackend;
+}) {
+ const summary = summarizeRunOn(backend);
+
+ return (
+
+ Run on
+ {summary.location === "local" ? (
+
+ This computer
+
+ ) : (
+
+
+ {summary.providerId}
+
+ {summary.rows.length > 0 ? (
+
+ {summary.rows.map((row) => (
+
+
{row.label}
+
+ {row.value}
+
+
+ ))}
+
+ ) : (
+
+ No saved settings — the provider applies its defaults.
+
+ )}
+
+ )}
+
+ These are the settings saved when the agent was created. Where an agent
+ runs can't be changed afterwards — create a new agent to run
+ somewhere else.
+
+ );
+}
+
export function BuzzAgentModelTuningFields({
envVars,
inheritedEnvVars,
@@ -257,105 +348,6 @@ export function BuzzAgentModelTuningFields({
blank to inherit from the global or persona default.
- Maximum context window tokens buzz-agent tracks before a handoff.
- Leave blank to inherit.
-
-
);
diff --git a/desktop/src/features/agents/ui/cardMintKeyUtils.ts b/desktop/src/features/agents/ui/cardMintKeyUtils.ts
new file mode 100644
index 0000000000..4364267e60
--- /dev/null
+++ b/desktop/src/features/agents/ui/cardMintKeyUtils.ts
@@ -0,0 +1,75 @@
+import type { CardMintKeyLayer } from "@/shared/api/tauriPersonas";
+
+/**
+ * Pure derivations for the key-setup panel visibility in `AgentCardMintDialog`.
+ *
+ * Extracted so that: (a) the component imports and uses the exact same logic
+ * as the tests verify, and (b) changes to panel conditions are caught by
+ * test failures rather than silently diverging.
+ */
+
+/** Whether the key panel should be shown (setup or user-initiated update only). */
+export function showKeyPanel(
+ keyLayer: CardMintKeyLayer | undefined,
+ editingKey: boolean,
+): boolean {
+ return keyLayer === "none" || editingKey;
+}
+
+/**
+ * Whether the resolved layer is writable from the dialog.
+ * Only `"global"` (and unset/`"none"`) can be updated via the dialog seam.
+ */
+export function isWritableLayer(
+ keyLayer: CardMintKeyLayer | undefined,
+): boolean {
+ return keyLayer === "global" || keyLayer === "none";
+}
+
+/**
+ * Whether the resolved layer cannot be updated from the dialog (key would be
+ * shadowed by the higher-priority layer even if global were updated).
+ */
+export function isReadOnlyLayer(
+ keyLayer: CardMintKeyLayer | undefined,
+): boolean {
+ return (
+ keyLayer === "agent" || keyLayer === "persona" || keyLayer === "process"
+ );
+}
+
+/** Whether the "Cancel" button should be shown (update mode only, with a mint form to return to). */
+export function showCancelButton(
+ keyLayer: CardMintKeyLayer | undefined,
+ editingKey: boolean,
+): boolean {
+ return editingKey && keyLayer !== "none";
+}
+
+/** Whether the "Using your saved OpenAI key · Update" status row should be shown. */
+export function showKeyStatusRow(
+ keyLayer: CardMintKeyLayer | undefined,
+ editingKey: boolean,
+): boolean {
+ return keyLayer === "global" && !editingKey;
+}
+
+/** Whether the read-only provenance row should be shown on the mint form. */
+export function showReadOnlyRow(
+ keyLayer: CardMintKeyLayer | undefined,
+ editingKey: boolean,
+): boolean {
+ return isReadOnlyLayer(keyLayer) && !editingKey;
+}
+
+/** The header title for the key-setup panel. */
+export function keyPanelTitle(
+ keyLayer: CardMintKeyLayer | undefined,
+ editingKey: boolean,
+): string {
+ if (isReadOnlyLayer(keyLayer)) return "OpenAI API key";
+ if (keyLayer === "none") return "One-time setup: OpenAI API key";
+ return editingKey
+ ? "Update OpenAI API key"
+ : "One-time setup: OpenAI API key";
+}
diff --git a/desktop/src/features/agents/ui/effortTable.fixture.json b/desktop/src/features/agents/ui/effortTable.fixture.json
index ed44c7581b..d097bc995f 100644
--- a/desktop/src/features/agents/ui/effortTable.fixture.json
+++ b/desktop/src/features/agents/ui/effortTable.fixture.json
@@ -41,6 +41,13 @@
"validValues": ["low", "medium", "high", "xhigh", "max"],
"defaultValue": "high"
},
+ {
+ "note": "Anthropic adaptive xhigh-capable: claude-opus-5",
+ "provider": "anthropic",
+ "model": "claude-opus-5",
+ "validValues": ["low", "medium", "high", "xhigh", "max"],
+ "defaultValue": "high"
+ },
{
"note": "Anthropic adaptive xhigh-capable: claude-mythos-5",
"provider": "anthropic",
@@ -202,6 +209,13 @@
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
"defaultValue": "medium"
},
+ {
+ "note": "openrouter: all-7 with medium default",
+ "provider": "openrouter",
+ "model": "",
+ "validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
+ "defaultValue": "medium"
+ },
{
"note": "empty provider: all-7 with medium default",
"provider": "",
diff --git a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs
new file mode 100644
index 0000000000..7ad726352f
--- /dev/null
+++ b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { resolveCatalogOwnerLabel } from "./PersonaCatalogDialog.tsx";
+
+// ── null / undefined summary ──────────────────────────────────────────────────
+
+test("test_null_summary_returns_community_member", () => {
+ assert.equal(resolveCatalogOwnerLabel(null), "Community member");
+});
+
+test("test_undefined_summary_returns_community_member", () => {
+ assert.equal(resolveCatalogOwnerLabel(undefined), "Community member");
+});
+
+// ── populated displayName ─────────────────────────────────────────────────────
+
+test("test_display_name_present_returns_display_name", () => {
+ assert.equal(
+ resolveCatalogOwnerLabel({ displayName: "Alice", name: "alice" }),
+ "Alice",
+ );
+});
+
+test("test_display_name_present_without_name_returns_display_name", () => {
+ assert.equal(resolveCatalogOwnerLabel({ displayName: "Alice" }), "Alice");
+});
+
+// ── empty / whitespace displayName with valid name ────────────────────────────
+
+test("test_empty_display_name_falls_through_to_name", () => {
+ assert.equal(
+ resolveCatalogOwnerLabel({ displayName: "", name: "alice" }),
+ "alice",
+ );
+});
+
+test("test_whitespace_only_display_name_falls_through_to_name", () => {
+ assert.equal(
+ resolveCatalogOwnerLabel({ displayName: " ", name: "alice" }),
+ "alice",
+ );
+});
+
+// ── both candidates absent / empty ────────────────────────────────────────────
+
+test("test_both_null_returns_community_member", () => {
+ assert.equal(
+ resolveCatalogOwnerLabel({ displayName: null, name: null }),
+ "Community member",
+ );
+});
+
+test("test_both_empty_returns_community_member", () => {
+ assert.equal(
+ resolveCatalogOwnerLabel({ displayName: "", name: "" }),
+ "Community member",
+ );
+});
+
+test("test_both_whitespace_returns_community_member", () => {
+ assert.equal(
+ resolveCatalogOwnerLabel({ displayName: " ", name: "\t" }),
+ "Community member",
+ );
+});
+
+test("test_display_name_absent_name_present_returns_name", () => {
+ assert.equal(resolveCatalogOwnerLabel({ name: "alice" }), "alice");
+});
+
+test("test_display_name_null_name_present_returns_name", () => {
+ assert.equal(
+ resolveCatalogOwnerLabel({ displayName: null, name: "alice" }),
+ "alice",
+ );
+});
diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts
index 53c5e7a16f..dd4b0b5209 100644
--- a/desktop/src/features/agents/ui/personaLibraryCopy.ts
+++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts
@@ -14,14 +14,13 @@ export const personaLibraryCopy = {
export const personaCatalogCopy = {
title: "Agent Catalog",
- description: "Browse built-in agents and add them to My Agents.",
- dialogTitle: "Agent Catalog",
- dialogDescription: "Browse built-in agents and add them to My Agents.",
+ description: "Browse agents shared to this relay.",
+ dialogTitle: "Add agent",
+ dialogDescription: "Browse agents shared to this relay.",
emptyTitle: "You're all set",
emptyDescription: "Everything in Agent Catalog is already in My Agents.",
- emptyCatalogDescription:
- "New agents will show up here when the app ships more options.",
- emptyCatalogTitle: "No agents in the catalog yet",
+ emptyCatalogDescription: "Shared agents will appear here.",
+ emptyCatalogTitle: "No agents are being shared",
detailsAction: "View details",
selectAction: "Choose",
deselectAction: "Deselect",
diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs
index bf21bc92ff..548c9ccc0a 100644
--- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs
+++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs
@@ -21,7 +21,8 @@ test("model discovery status names missing OpenAI-compatible credentials", () =>
);
assert.equal(status?.tone, "warning");
- assert.match(status?.message ?? "", /OpenAI API key/);
+ assert.match(status?.message ?? "", /OpenAI runtime API key/);
+ assert.match(status?.message ?? "", /OPENAI_COMPAT_API_KEY/);
assert.match(status?.message ?? "", /OpenAI models/);
});
@@ -67,3 +68,46 @@ test("model discovery status stays quiet for missing Databricks defaults", () =>
assert.equal(status, null);
});
+
+test("auth-required errors name the agent and ask for sign-in", () => {
+ // Real shape from run_agent_models_command wrapping buzz-acp stderr when
+ // cursor-agent is signed out (spec ErrorCode::AuthRequired text).
+ const status = formatModelDiscoveryErrorStatus(
+ new Error(
+ "buzz-acp models failed (exit 1): agent communication failed: Agent reported error (code -32000): Authentication required",
+ ),
+ "",
+ "Cursor",
+ );
+
+ assert.equal(status?.tone, "warning");
+ assert.match(status?.message ?? "", /Cursor requires sign-in/);
+ assert.match(status?.message ?? "", /Sign in with the Cursor CLI/);
+});
+
+test("auth-required copy degrades gracefully without an agent label", () => {
+ const status = formatModelDiscoveryErrorStatus(
+ new Error("Agent reported error (code -32000): Authentication required"),
+ "",
+ );
+
+ assert.equal(status?.tone, "warning");
+ assert.match(status?.message ?? "", /This agent requires sign-in/);
+});
+
+test("non-auth -32000 errors do NOT get the sign-in copy", () => {
+ // -32000 is the catch-all fallback code for unclassified agent errors
+ // (agent_error_from_json unwrap_or(-32000)); only the spec-reserved
+ // "Authentication required" text may route to the sign-in message.
+ const status = formatModelDiscoveryErrorStatus(
+ new Error(
+ "buzz-acp models failed (exit 1): Agent reported error (code -32000): model catalog fetch timed out",
+ ),
+ "anthropic",
+ "Cursor",
+ );
+
+ assert.equal(status?.tone, "warning");
+ assert.doesNotMatch(status?.message ?? "", /sign-in/i);
+ assert.match(status?.message ?? "", /Using built-in model options/);
+});
diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts
index 38138429f8..b943f6455c 100644
--- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts
+++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts
@@ -44,6 +44,7 @@ function isEmptySharedComputeError(message: string): boolean {
export function formatModelDiscoveryErrorStatus(
error: unknown,
provider: string,
+ agentLabel?: string,
): PersonaModelDiscoveryStatus | null {
const message = errorMessage(error);
@@ -87,6 +88,19 @@ export function formatModelDiscoveryErrorStatus(
};
}
+ // Spec-reserved auth error text (agent-client-protocol ErrorCode::AuthRequired),
+ // surfaced verbatim through buzz-acp's stderr — generic across conformant
+ // harnesses (e.g. cursor-agent when not signed in). Match the message text,
+ // NOT code -32000: that code is also the catch-all fallback for unclassified
+ // errors, so matching it would swallow unrelated failures into "sign in".
+ if (message.toLowerCase().includes("authentication required")) {
+ const label = agentLabel?.trim();
+ return {
+ message: `${label || "This agent"} requires sign-in before models can load. Sign in with the ${label || "agent's"} CLI in a terminal, then try again.`,
+ tone: "warning",
+ };
+ }
+
if (message.includes("ANTHROPIC_API_KEY required")) {
return {
message: "Enter an Anthropic API key to load Anthropic models.",
@@ -96,7 +110,8 @@ export function formatModelDiscoveryErrorStatus(
if (message.includes("OPENAI_COMPAT_API_KEY required")) {
return {
- message: "Enter an OpenAI API key to load OpenAI models.",
+ message:
+ "Enter an OpenAI runtime API key (OPENAI_COMPAT_API_KEY) to load OpenAI models.",
tone: "warning",
};
}
diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts
index d138296b5c..20d789c4eb 100644
--- a/desktop/src/features/agents/ui/personaRuntimeModel.ts
+++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts
@@ -109,24 +109,27 @@ export function hasMissingRequiredEnvKey(
/**
* Resolve the provider and env-vars to PERSIST on Save.
*
- * The spawn path reads ONLY the record snapshot (`record.provider`/
- * `record.env_vars`), never the live persona, and the record is authoritative:
- * `env_vars` is the complete pinned map (persona env snapshotted at create,
- * already merged UNDER the agent's own overrides), and the provider field is
- * user-editable in the dialog even while inheriting. So the local edit state IS
- * the record's own value and is honored verbatim in the normal case.
+ * This dialog edits an agent INSTANCE's own fields (`record.provider`/
+ * `record.env_vars`), which remain the source of truth for a definition-less
+ * (legacy) instance and for any local/display state before the next resolve.
+ * For a LINKED instance, spawn/deploy resolve the effective provider/model
+ * from the definition, never from the record's own `provider`/`model` bytes
+ * (see `effective_config::resolve_effective_config` on the desktop backend) —
+ * so persisting `record.provider` here only matters for definition-less
+ * instances and for keeping the record's display/legacy fields consistent;
+ * it does not change what a linked instance spawns with.
*
- * The ONE exception is the inherit-TRANSITION-from-a-harness-pin: a previously
- * harness-pinned agent (e.g. Claude — `agent.agentCommandOverride != null` at
- * dialog open) has its `provider` cleared and carries no persona credential,
- * then the user checks "Inherit runtime from template" for a provider-backed
- * persona (e.g. buzz-agent/Anthropic). Persisting the local (empty) provider +
- * credential-less env would save an agent that fails readiness on next start.
+ * The one case this function still has to handle carefully is the
+ * inherit-TRANSITION-from-a-harness-pin: a previously harness-pinned agent
+ * (e.g. Claude — `agent.agentCommandOverride != null` at dialog open) has its
+ * `provider` cleared and carries no persona credential, then the user checks
+ * "Inherit runtime from template" for a provider-backed persona (e.g.
+ * buzz-agent/Anthropic). Persisting the local (empty) provider + credential-
+ * less env would leave a definition-less instance's record fields blank.
* Only in that case — inheriting AND the local provider is empty AND the agent
* was harness-pinned at open — do we substitute the persona snapshot: the
* persona's provider and the persona-layered env (`{ ...personaEnv,
- * ...agentEnv }`, agent layer wins to mirror spawn-time layering), matching
- * create-time record pinning.
+ * ...agentEnv }`, agent layer wins to mirror spawn-time layering).
*
* A NON-empty local provider while inheriting (e.g. an Anthropic-persona agent
* the user re-points to Databricks) is a deliberate edit and passes through
@@ -135,11 +138,11 @@ export function hasMissingRequiredEnvKey(
* MODEL follows the same transition rule. buzz-agent/goose readiness requires a
* model, but a Claude-pinned agent's record often carries no model (Claude
* resolves its own). On the inherit-transition to a provider-backed persona with
- * a set `persona.model`, persisting the empty local model would save an agent
- * that inherits the provider + credentials but no model and fails readiness on
- * next start — so we substitute `personaModel` in that same case. A non-empty
- * local model (deliberate pick) always passes through; an empty local model in
- * steady state stays empty (runtime default), same authoritative logic.
+ * a set `persona.model`, persisting the empty local model would save a
+ * record-level model gap — so we substitute `personaModel` in that same case. A
+ * non-empty local model (deliberate pick) always passes through; an empty local
+ * model in steady state stays empty (runtime default), same authoritative
+ * logic.
*
* An EMPTY local provider while inheriting on an agent that was ALREADY
* inheriting at open (`agentWasHarnessPinned` false) is ALSO authoritative: the
@@ -148,8 +151,6 @@ export function hasMissingRequiredEnvKey(
* resurrect the persona provider — otherwise the Default option could never
* actually clear an inherited agent's provider override.
*
- * The result is the SAME effective value the required-credential gate
- * validates, so the gate, the submitted record, and the spawn snapshot agree.
* Provider is normalized: trimmed, empty → `null`.
*/
export function resolveInheritedRuntimeSubmission(input: {
diff --git a/desktop/src/features/agents/ui/relayMeshModelPicker.test.mjs b/desktop/src/features/agents/ui/relayMeshModelPicker.test.mjs
index 3dd1b7c7b3..ce002dfe61 100644
--- a/desktop/src/features/agents/ui/relayMeshModelPicker.test.mjs
+++ b/desktop/src/features/agents/ui/relayMeshModelPicker.test.mjs
@@ -9,7 +9,7 @@ import { AUTO_MODEL_DROPDOWN_VALUE } from "./agentConfigOptions.tsx";
const fallback = [{ id: "", label: "Default model" }];
const live = [
- { id: "", label: "Default (auto)" },
+ { id: "", label: "Auto (collective when available)" },
{ id: "mesh/model", label: "mesh/model" },
];
@@ -35,7 +35,9 @@ test("Buzz shared compute fallback is Default auto while normal providers remain
model: "",
provider: "relay-mesh",
});
- assert.deepEqual(mesh.options, [{ id: "", label: "Default (auto)" }]);
+ assert.deepEqual(mesh.options, [
+ { id: "", label: "Auto (collective when available)" },
+ ]);
const openai = relayMeshModelPickerState({
discoveredOptions: null,
@@ -57,7 +59,9 @@ test("Buzz shared compute keeps Default auto when discovery is empty", () => {
provider: "relay-mesh",
});
- assert.deepEqual(state.options, [{ id: "", label: "Default (auto)" }]);
+ assert.deepEqual(state.options, [
+ { id: "", label: "Auto (collective when available)" },
+ ]);
assert.equal(state.selectValue, AUTO_MODEL_DROPDOWN_VALUE);
assert.equal(state.showCustomInput, false);
});
@@ -71,6 +75,6 @@ test("Buzz shared compute dropdown contains Default plus live models and no cust
});
assert.deepEqual(
options.map((option) => option.label),
- ["Default (auto)", "mesh/model"],
+ ["Auto (collective when available)", "mesh/model"],
);
});
diff --git a/desktop/src/features/agents/ui/relayMeshModelPicker.ts b/desktop/src/features/agents/ui/relayMeshModelPicker.ts
index 2e3cd93eb2..dee401824a 100644
--- a/desktop/src/features/agents/ui/relayMeshModelPicker.ts
+++ b/desktop/src/features/agents/ui/relayMeshModelPicker.ts
@@ -12,7 +12,10 @@ function withSharedComputeAutoOption(
options: readonly PersonaModelOption[],
): readonly PersonaModelOption[] {
const modelOptions = options.filter((option) => option.id.trim() !== "");
- return [{ id: "", label: "Default (auto)" }, ...modelOptions];
+ return [
+ { id: "", label: "Auto (collective when available)" },
+ ...modelOptions,
+ ];
}
export function relayMeshModelPickerState({
diff --git a/desktop/src/features/agents/ui/respondToFieldContract.test.mjs b/desktop/src/features/agents/ui/respondToFieldContract.test.mjs
new file mode 100644
index 0000000000..91b4c6d1f6
--- /dev/null
+++ b/desktop/src/features/agents/ui/respondToFieldContract.test.mjs
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+const respondToFieldSource = await readFile(
+ new URL("./RespondToField.tsx", import.meta.url),
+ "utf8",
+);
+
+/**
+ * Copy assertions run against this rather than the raw source: JSX text wraps
+ * wherever the formatter decides, and a sentence split across lines should not
+ * fail a copy test.
+ */
+const collapsedSource = respondToFieldSource.replace(/\s+/g, " ");
+
+for (const label of ["Only me (default)", "Selected people", "Anyone"]) {
+ test(`respond-to control uses the plain-language label: ${label}`, () => {
+ assert.ok(respondToFieldSource.includes(`label: "${label}"`));
+ });
+}
+
+test("native and persona controls share one option list", () => {
+ assert.match(
+ respondToFieldSource,
+ /
+ {/* The activity accessory is anchored in the dock's reserved
+ bottom rail, so fading it cannot change the observed
+ overlay height or move the conversation. Its natural
+ content height remains responsive. */}
+
- {unreadOnly ? "No unread messages" : "No messages found"}
+ {unreadOnly
+ ? INBOX_UNREAD_EMPTY_STATE_TITLES[filter]
+ : INBOX_EMPTY_STATE_TITLES[filter]}
{unreadOnly
- ? "Turn off the unread filter to see read messages."
- : "Switch back to all mail to see more messages."}
+ ? "Turn off Show unread only to see read activity."
+ : filter === "all"
+ ? "New activity will appear here."
+ : "Switch back to All to see other activity."}