mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Allow hiding agent harness options
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user