Allow hiding agent harness options

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-07-27 13:24:50 +01:00
parent 7fc0cc82db
commit f07dfcfd91
8 changed files with 319 additions and 24 deletions
+8 -1
View File
@@ -85,7 +85,14 @@ 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
9. **Runtime enablement is a device-local menu preference, not a capability or
lifecycle fact.** Settings persists disabled runtime IDs through
`lib/runtimeVisibilityPreference.ts`; harness dropdowns filter those IDs
from new choices while the raw Rust catalog remains authoritative for
installation, capabilities, and existing agent configuration. Turning a
runtime off must not uninstall it, stop it, or invalidate an existing
agent that already uses it.
10. **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,
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ACP_RUNTIME_VISIBILITY_STORAGE_KEY,
filterEnabledAcpRuntimes,
nextDisabledAcpRuntimeIds,
parseDisabledAcpRuntimeIds,
readDisabledAcpRuntimeIds,
} from "./runtimeVisibilityPreference.ts";
test("runtime visibility parsing is normalized and corruption tolerant", () => {
assert.deepEqual(
parseDisabledAcpRuntimeIds('[" Goose ","codex","goose",4]'),
["codex", "goose"],
);
assert.deepEqual(parseDisabledAcpRuntimeIds("{not-json"), []);
assert.deepEqual(parseDisabledAcpRuntimeIds('{"goose":false}'), []);
});
test("enabling and disabling runtimes preserves the other choices", () => {
const disabled = nextDisabledAcpRuntimeIds([], " Goose ", false);
assert.deepEqual(disabled, ["goose"]);
assert.deepEqual(nextDisabledAcpRuntimeIds(disabled, "codex", false), [
"codex",
"goose",
]);
assert.deepEqual(nextDisabledAcpRuntimeIds(disabled, "GOOSE", true), []);
});
test("disabled runtimes are removed from selectable catalog entries", () => {
const runtimes = [
{ id: "buzz-agent", label: "Buzz Agent" },
{ id: "goose", label: "Goose" },
{ id: "codex", label: "Codex" },
];
assert.deepEqual(filterEnabledAcpRuntimes(runtimes, ["Goose"]), [
runtimes[0],
runtimes[2],
]);
});
test("stored runtime visibility is read from the versioned device key", () => {
const storage = {
getItem(key) {
assert.equal(key, ACP_RUNTIME_VISIBILITY_STORAGE_KEY);
return '["claude"]';
},
};
assert.deepEqual(readDisabledAcpRuntimeIds(storage), ["claude"]);
});
@@ -0,0 +1,185 @@
import * as React from "react";
export const ACP_RUNTIME_VISIBILITY_STORAGE_KEY =
"buzz-agent-runtime-visibility.v1";
type RuntimeVisibilityStorage = Pick<Storage, "getItem" | "setItem">;
const EMPTY_DISABLED_RUNTIME_IDS: readonly string[] = Object.freeze([]);
const listeners = new Set<() => void>();
let cachedSerializedValue: string | null | undefined;
let cachedDisabledRuntimeIds = EMPTY_DISABLED_RUNTIME_IDS;
function normalizeRuntimeId(runtimeId: string): string {
return runtimeId.trim().toLowerCase();
}
function getLocalStorage(): RuntimeVisibilityStorage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export function parseDisabledAcpRuntimeIds(
serialized: string | null,
): readonly string[] {
if (!serialized) return EMPTY_DISABLED_RUNTIME_IDS;
try {
const parsed: unknown = JSON.parse(serialized);
if (!Array.isArray(parsed)) return EMPTY_DISABLED_RUNTIME_IDS;
const runtimeIds = [
...new Set(
parsed
.filter((value): value is string => typeof value === "string")
.map(normalizeRuntimeId)
.filter(Boolean),
),
].sort();
return runtimeIds.length > 0
? Object.freeze(runtimeIds)
: EMPTY_DISABLED_RUNTIME_IDS;
} catch {
return EMPTY_DISABLED_RUNTIME_IDS;
}
}
export function readDisabledAcpRuntimeIds(
storage: Pick<RuntimeVisibilityStorage, "getItem">,
): readonly string[] {
try {
return parseDisabledAcpRuntimeIds(
storage.getItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY),
);
} catch {
return EMPTY_DISABLED_RUNTIME_IDS;
}
}
export function nextDisabledAcpRuntimeIds(
current: readonly string[],
runtimeId: string,
enabled: boolean,
): readonly string[] {
const normalizedRuntimeId = normalizeRuntimeId(runtimeId);
if (!normalizedRuntimeId) return current;
const next = new Set(current.map(normalizeRuntimeId).filter(Boolean));
if (enabled) {
next.delete(normalizedRuntimeId);
} else {
next.add(normalizedRuntimeId);
}
const runtimeIds = [...next].sort();
return runtimeIds.length > 0
? Object.freeze(runtimeIds)
: EMPTY_DISABLED_RUNTIME_IDS;
}
export function filterEnabledAcpRuntimes<T extends { id: string }>(
runtimes: readonly T[],
disabledRuntimeIds: readonly string[],
): T[] {
if (disabledRuntimeIds.length === 0) return [...runtimes];
const disabled = new Set(disabledRuntimeIds.map(normalizeRuntimeId));
return runtimes.filter(
(runtime) => !disabled.has(normalizeRuntimeId(runtime.id)),
);
}
function getDisabledRuntimeIdsSnapshot(): readonly string[] {
const storage = getLocalStorage();
if (!storage) return EMPTY_DISABLED_RUNTIME_IDS;
let serialized: string | null;
try {
serialized = storage.getItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY);
} catch {
return EMPTY_DISABLED_RUNTIME_IDS;
}
if (serialized === cachedSerializedValue) {
return cachedDisabledRuntimeIds;
}
cachedSerializedValue = serialized;
cachedDisabledRuntimeIds = parseDisabledAcpRuntimeIds(serialized);
return cachedDisabledRuntimeIds;
}
function subscribeToRuntimeVisibility(onStoreChange: () => void): () => void {
listeners.add(onStoreChange);
const handleStorage = (event: StorageEvent) => {
if (
event.key !== null &&
event.key !== ACP_RUNTIME_VISIBILITY_STORAGE_KEY
) {
return;
}
cachedSerializedValue = undefined;
onStoreChange();
};
window.addEventListener("storage", handleStorage);
return () => {
listeners.delete(onStoreChange);
window.removeEventListener("storage", handleStorage);
};
}
export function setAcpRuntimeEnabled(
runtimeId: string,
enabled: boolean,
): boolean {
const storage = getLocalStorage();
if (!storage) return false;
const current = getDisabledRuntimeIdsSnapshot();
const next = nextDisabledAcpRuntimeIds(current, runtimeId, enabled);
if (next === current) return true;
const serialized = JSON.stringify(next);
try {
storage.setItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY, serialized);
} catch {
return false;
}
cachedSerializedValue = serialized;
cachedDisabledRuntimeIds = next;
for (const listener of listeners) listener();
return true;
}
export function useDisabledAcpRuntimeIds(): readonly string[] {
return React.useSyncExternalStore(
subscribeToRuntimeVisibility,
getDisabledRuntimeIdsSnapshot,
() => EMPTY_DISABLED_RUNTIME_IDS,
);
}
export function useSelectableAcpRuntimes<T extends { id: string }>(
runtimes: readonly T[],
): T[] {
const disabledRuntimeIds = useDisabledAcpRuntimeIds();
return React.useMemo(
() => filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds),
[disabledRuntimeIds, runtimes],
);
}
export function useAcpRuntimeEnabled(runtimeId: string): boolean {
const disabledRuntimeIds = useDisabledAcpRuntimeIds();
const normalizedRuntimeId = normalizeRuntimeId(runtimeId);
return !disabledRuntimeIds.includes(normalizedRuntimeId);
}
@@ -39,6 +39,7 @@ import {
} from "@/features/agents/ui/AgentConfigFields";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference";
type SaveState = "idle" | "saving" | "saved" | "error";
@@ -135,9 +136,10 @@ export function AgentDefaultsEditor({
}, []);
const runtimesQuery = useAcpRuntimesQuery();
const selectableRuntimes = useSelectableAcpRuntimes(runtimesQuery.data ?? []);
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimesQuery.data ?? []),
[runtimesQuery.data],
() => sortPersonaRuntimes(selectableRuntimes),
[selectableRuntimes],
);
// An unset preferred runtime uses the same Buzz Agent-first fallback as
// deployment. The rendered draft below carries that fallback forward so the
@@ -83,6 +83,7 @@ import {
} from "./agentAiConfigurationPolicy";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference";
type AgentDefinitionDialogProps = {
open: boolean;
@@ -150,10 +151,8 @@ export function AgentDefinitionDialog({
// the seeded runtime from the submit payload for builtin definitions whose
// canonical runtime is null — the sync would revert it anyway.
const isRuntimeAutoSeededRef = React.useRef(false);
// Guards the seeding effect so it fires at most once per dialog-open.
// Without this, clearing runtime back to "" via "No preference" would re-
// trigger the effect (the `runtime` dep would pass the length guard) and
// snap the dropdown back to the default — an edit-mode regression.
// Seed once per open so choosing "No preference" cannot snap the dropdown
// back to the default.
const hasSeededForOpenRef = React.useRef(false);
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [isAvatarUploadPending, setIsAvatarUploadPending] =
@@ -166,9 +165,10 @@ export function AgentDefinitionDialog({
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ open });
const defaultRuntime = React.useMemo(
() => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime),
[globalConfig.preferred_runtime, runtimes],
const selectableRuntimes = useSelectableAcpRuntimes(runtimes);
const defaultRuntime = getDefaultPersonaRuntime(
selectableRuntimes,
globalConfig.preferred_runtime,
);
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
const shouldReduceMotion = useReducedMotion();
@@ -554,8 +554,8 @@ export function AgentDefinitionDialog({
llmProviderFieldVisible && isCustomProviderEditing;
const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE;
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimes),
[runtimes],
() => sortPersonaRuntimes(selectableRuntimes),
[selectableRuntimes],
);
const blankRuntimeOptionLabel = runtimesLoading
? "Loading harnesses..."
@@ -80,6 +80,7 @@ import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference";
import { resolveModelFieldStatusMessage } from "./agentConfigControls";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning";
@@ -111,6 +112,7 @@ export function AgentInstanceEditDialog({
const runtimesQuery = useAcpRuntimesQuery({ enabled: open });
const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null);
const runtimes = runtimesQuery.data ?? [];
const selectableRuntimes = useSelectableAcpRuntimes(runtimes);
const [name, setName] = React.useState(agent.name);
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
@@ -215,8 +217,8 @@ export function AgentInstanceEditDialog({
// Build the sorted runtime catalog for the dropdown.
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimes),
[runtimes],
() => sortPersonaRuntimes(selectableRuntimes),
[selectableRuntimes],
);
const selectedRuntime = React.useMemo(
@@ -9,6 +9,10 @@ import {
useGitBashPrerequisiteQuery,
useInstallAcpRuntimeMutation,
} from "@/features/agents/hooks";
import {
setAcpRuntimeEnabled,
useAcpRuntimeEnabled,
} from "@/features/agents/lib/runtimeVisibilityPreference";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { RuntimeIcon } from "@/features/onboarding/ui/RuntimeIcon";
import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types";
@@ -185,6 +189,8 @@ function RuntimeActions({
}) {
const isAvailable = runtime.availability === "available";
const canInstall = runtime.canAutoInstall && !runtime.nodeRequired;
const isEnabled = useAcpRuntimeEnabled(runtime.id);
const isOn = isAvailable && isEnabled;
const isWorking = isInstalling || isConnecting;
return (
@@ -206,13 +212,13 @@ function RuntimeActions({
</div>
) : (
<Switch
aria-label={`${runtime.label} availability`}
checked={isAvailable}
className="disabled:cursor-default disabled:opacity-100"
aria-label={`${runtime.label} enabled`}
checked={isOn}
data-testid={`doctor-runtime-toggle-${runtime.id}`}
disabled={isAvailable || !canInstall}
disabled={!isAvailable && !canInstall}
onCheckedChange={(checked) => {
if (checked) {
setAcpRuntimeEnabled(runtime.id, checked);
if (checked && !isAvailable) {
onInstall();
}
}}
@@ -574,7 +580,7 @@ export function DoctorSettingsPanel() {
<SectionHeader
className="items-center"
title="Agent runtimes"
description="Choose which agent tools Buzz can use on this device."
description="Choose which harnesses appear when configuring agents. Turning one off does not uninstall it or affect existing agents."
action={
<Button
disabled={isRefreshing}
+44 -4
View File
@@ -208,9 +208,7 @@ test.describe("Doctor panel state screenshots", () => {
});
await page.keyboard.press("Escape");
await expect(page.getByTestId("doctor-runtime-toggle-goose")).toBeChecked();
await expect(
page.getByTestId("doctor-runtime-toggle-goose"),
).toBeDisabled();
await expect(page.getByTestId("doctor-runtime-toggle-goose")).toBeEnabled();
await expect(page.getByTestId("doctor-runtime-codex")).not.toContainText(
"Not installed",
);
@@ -233,6 +231,48 @@ test.describe("Doctor panel state screenshots", () => {
});
});
test("available runtimes can be hidden from agent harness menus", async ({
page,
}) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
GOOSE_AVAILABLE,
CLAUDE_AVAILABLE_LOGGED_IN,
BUZZ_AGENT_AVAILABLE,
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "agents");
const gooseToggle = page.getByTestId("doctor-runtime-toggle-goose");
await expect(gooseToggle).toBeChecked();
await expect(gooseToggle).toBeEnabled();
await gooseToggle.click();
await expect(gooseToggle).not.toBeChecked();
await page.getByRole("button", { name: "Back to app" }).click();
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create from scratch" }).click();
const harnessDropdown = page.locator("#persona-runtime");
await expect(harnessDropdown).toContainText("Buzz Agent");
await harnessDropdown.press("Enter");
await expect(
page.getByRole("menuitemradio", { name: /^Goose/ }),
).toHaveCount(0);
await expect(
page.getByRole("menuitemradio", { name: /^Buzz Agent/ }),
).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" });
await openSettings(page, "agents");
await expect(
page.getByTestId("doctor-runtime-toggle-goose"),
).not.toBeChecked();
});
/** 01 — a ready runtime stays compact without redundant status copy. */
test("01-auth-logged-in", async ({ page }) => {
await installMockBridge(page, {
@@ -560,7 +600,7 @@ test.describe("Doctor panel state screenshots", () => {
await expect(toggle).toBeEnabled();
await toggle.click();
await expect(toggle).toBeChecked({ timeout: 5_000 });
await expect(toggle).toBeDisabled();
await expect(toggle).toBeEnabled();
await expect(page.getByTestId("doctor-runtime-guidance-codex")).toHaveCount(
0,
);