diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index b2a1b99c4..b7dfc40f2 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -280,6 +280,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
| Router | Endpoints |
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
+| **Features** (`/api/features`) | `GET /` - report config-gated feature availability (currently `agents_api.enabled`) for frontend UI gating |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py
index 9633f3708..26041cd75 100644
--- a/backend/app/gateway/app.py
+++ b/backend/app/gateway/app.py
@@ -18,6 +18,7 @@ from app.gateway.routers import (
auth,
channel_connections,
channels,
+ features,
feedback,
mcp,
memory,
@@ -374,6 +375,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Models API is mounted at /api/models
app.include_router(models.router)
+ # Features API is mounted at /api/features
+ app.include_router(features.router)
+
# MCP API is mounted at /api/mcp
app.include_router(mcp.router)
diff --git a/backend/app/gateway/routers/features.py b/backend/app/gateway/routers/features.py
new file mode 100644
index 000000000..2b1b38326
--- /dev/null
+++ b/backend/app/gateway/routers/features.py
@@ -0,0 +1,40 @@
+"""Read-only feature-flag endpoint for the frontend bootstrap.
+
+Reports which optional, config-gated features are exposed over HTTP so the
+frontend can gate UI and avoid firing requests that the backend would reject
+with 403. Reads through ``get_config`` so edits to ``config.yaml`` take effect
+on the next request without a restart (config hot-reload boundary).
+"""
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+
+from app.gateway.deps import get_config
+from deerflow.config.app_config import AppConfig
+
+router = APIRouter(prefix="/api", tags=["features"])
+
+
+class AgentsApiFeature(BaseModel):
+ """Availability of the custom-agent management API."""
+
+ enabled: bool = Field(..., description="Whether the agents_api routes are exposed over HTTP")
+
+
+class FeaturesResponse(BaseModel):
+ """Frontend-facing feature availability flags."""
+
+ agents_api: AgentsApiFeature
+
+
+@router.get(
+ "/features",
+ response_model=FeaturesResponse,
+ summary="List Feature Flags",
+ description="Report which optional config-gated features are enabled, so the frontend can gate UI before issuing requests.",
+)
+async def list_features(config: AppConfig = Depends(get_config)) -> FeaturesResponse:
+ """Return availability of optional, config-gated frontend features."""
+ return FeaturesResponse(
+ agents_api=AgentsApiFeature(enabled=config.agents_api.enabled),
+ )
diff --git a/backend/tests/test_features_router.py b/backend/tests/test_features_router.py
new file mode 100644
index 000000000..e71dafbd1
--- /dev/null
+++ b/backend/tests/test_features_router.py
@@ -0,0 +1,29 @@
+from types import SimpleNamespace
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.gateway.deps import get_config
+from app.gateway.routers import features
+
+
+def _app_with_config(*, agents_api_enabled: bool) -> FastAPI:
+ app = FastAPI()
+ app.include_router(features.router)
+ fake_config = SimpleNamespace(agents_api=SimpleNamespace(enabled=agents_api_enabled))
+ app.dependency_overrides[get_config] = lambda: fake_config
+ return app
+
+
+def test_features_reports_agents_api_enabled() -> None:
+ with TestClient(_app_with_config(agents_api_enabled=True)) as client:
+ response = client.get("/api/features")
+ assert response.status_code == 200
+ assert response.json() == {"agents_api": {"enabled": True}}
+
+
+def test_features_reports_agents_api_disabled() -> None:
+ with TestClient(_app_with_config(agents_api_enabled=False)) as client:
+ response = client.get("/api/features")
+ assert response.status_code == 200
+ assert response.json() == {"agents_api": {"enabled": False}}
diff --git a/frontend/src/app/workspace/agents/layout.tsx b/frontend/src/app/workspace/agents/layout.tsx
new file mode 100644
index 000000000..9eb30eb5a
--- /dev/null
+++ b/frontend/src/app/workspace/agents/layout.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { AgentsFeatureDisabled } from "@/components/workspace/agents/agents-feature-disabled";
+import { useAgentsApiEnabled } from "@/core/agents";
+import { useI18n } from "@/core/i18n/hooks";
+
+export default function AgentsLayout({ children }: { children: ReactNode }) {
+ const { t } = useI18n();
+ const { enabled, isLoading } = useAgentsApiEnabled();
+
+ if (isLoading) {
+ return (
+
+ {t.common.loading}
+
+ );
+ }
+
+ if (!enabled) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/frontend/src/components/workspace/agents/agents-feature-disabled.tsx b/frontend/src/components/workspace/agents/agents-feature-disabled.tsx
new file mode 100644
index 000000000..451b01991
--- /dev/null
+++ b/frontend/src/components/workspace/agents/agents-feature-disabled.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { BotOffIcon } from "lucide-react";
+
+import { useI18n } from "@/core/i18n/hooks";
+
+export function AgentsFeatureDisabled() {
+ const { t } = useI18n();
+ return (
+
+
+
+
+
+
{t.agents.featureDisabledTitle}
+
+ {t.agents.featureDisabledDescription}
+
+
+
+ );
+}
diff --git a/frontend/src/components/workspace/workspace-nav-chat-list.tsx b/frontend/src/components/workspace/workspace-nav-chat-list.tsx
index 2028da0bd..0cd1ad66a 100644
--- a/frontend/src/components/workspace/workspace-nav-chat-list.tsx
+++ b/frontend/src/components/workspace/workspace-nav-chat-list.tsx
@@ -10,11 +10,18 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useAgentsApiEnabled } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavChatList() {
const { t } = useI18n();
const pathname = usePathname();
+ const { enabled: agentsEnabled } = useAgentsApiEnabled();
return (
@@ -27,15 +34,46 @@ export function WorkspaceNavChatList() {
-
-
-
- {t.sidebar.agents}
-
-
+ {agentsEnabled ? (
+
+
+
+ {t.sidebar.agents}
+
+
+ ) : (
+ // Disabled: aria-disabled drives the sidebar CVA to suppress
+ // pointer events on the button, so wrap it in a hoverable span
+ // that still surfaces the "feature not enabled" tooltip for mouse
+ // users. The button stays in the tab order (no tabIndex={-1}) and
+ // is wired via aria-describedby to a visually-hidden reason, so
+ // keyboard and screen-reader users also learn why it is disabled.
+
+
+ {/* cursor-not-allowed lives on the span (the element that
+ still receives pointer events), not the inert button. */}
+
+
+
+ {t.sidebar.agents}
+
+
+ {t.sidebar.agentsDisabledTooltip}
+
+
+
+
+ {t.sidebar.agentsDisabledTooltip}
+
+
+ )}
diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts
index 680d4c432..f407c565d 100644
--- a/frontend/src/core/agents/api.ts
+++ b/frontend/src/core/agents/api.ts
@@ -87,6 +87,19 @@ export async function deleteAgent(name: string): Promise {
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
}
+interface FeaturesResponse {
+ agents_api: { enabled: boolean };
+}
+
+export async function fetchAgentsApiEnabled(): Promise {
+ const res = await fetch(`${getBackendBaseURL()}/api/features`);
+ if (!res.ok) {
+ throw new Error(`Failed to load features: ${res.statusText}`);
+ }
+ const data = (await res.json()) as FeaturesResponse;
+ return data.agents_api.enabled;
+}
+
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
diff --git a/frontend/src/core/agents/feature-cache.ts b/frontend/src/core/agents/feature-cache.ts
new file mode 100644
index 000000000..76a6603ea
--- /dev/null
+++ b/frontend/src/core/agents/feature-cache.ts
@@ -0,0 +1,51 @@
+// Last-known persistence for the agents_api feature flag.
+//
+// /api/features is fail-open by design so an outage can never hide a working
+// feature. The symmetric risk is that when the feature is genuinely disabled
+// and /api/features is down, failing open re-mounts the agents UI and brings
+// back the 403 storm (#3757). Persisting the last definitive answer lets a
+// cold start during an outage fall back to it instead of failing open.
+
+const AGENTS_API_ENABLED_KEY = "deerflow.features.agents_api";
+
+function isBrowser(): boolean {
+ return typeof window !== "undefined";
+}
+
+/** The last definitive value observed from /api/features, or undefined. */
+export function readCachedAgentsApiEnabled(): boolean | undefined {
+ if (!isBrowser()) {
+ return undefined;
+ }
+ try {
+ const raw = window.localStorage.getItem(AGENTS_API_ENABLED_KEY);
+ if (raw === "true") return true;
+ if (raw === "false") return false;
+ } catch {}
+ return undefined;
+}
+
+export function writeCachedAgentsApiEnabled(value: boolean): void {
+ if (!isBrowser()) {
+ return;
+ }
+ try {
+ window.localStorage.setItem(AGENTS_API_ENABLED_KEY, String(value));
+ } catch {}
+}
+
+/**
+ * Resolve the effective flag from the live query value and the last-known
+ * cached value:
+ * - a live answer always wins;
+ * - otherwise fall back to the last value we successfully observed (sticky),
+ * so a transient /api/features outage cannot flip a disabled feature back on;
+ * - only fail open (true) when we have never had a definitive answer, so a
+ * genuinely working feature is never hidden by an outage.
+ */
+export function resolveAgentsApiEnabled(
+ live: boolean | undefined,
+ cached: boolean | undefined,
+): boolean {
+ return live ?? cached ?? true;
+}
diff --git a/frontend/src/core/agents/hooks.ts b/frontend/src/core/agents/hooks.ts
index c40f0daec..67eb316c0 100644
--- a/frontend/src/core/agents/hooks.ts
+++ b/frontend/src/core/agents/hooks.ts
@@ -1,14 +1,60 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
import {
createAgent,
deleteAgent,
+ fetchAgentsApiEnabled,
getAgent,
listAgents,
updateAgent,
} from "./api";
+import {
+ readCachedAgentsApiEnabled,
+ resolveAgentsApiEnabled,
+ writeCachedAgentsApiEnabled,
+} from "./feature-cache";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
+export function useAgentsApiEnabled() {
+ const { data, isPending } = useQuery({
+ queryKey: ["features", "agents_api"],
+ queryFn: () => fetchAgentsApiEnabled(),
+ // Re-check on every mount so flipping config.yaml + revisiting the
+ // agents section auto-enables the feature without a rebuild.
+ staleTime: 0,
+ refetchOnMount: true,
+ retry: false,
+ });
+
+ // localStorage only exists in the browser, so read the last-known value
+ // after mount (not during render). This keeps the first client render equal
+ // to the server's (cache unknown → fail open), avoiding a hydration mismatch
+ // on the non-loading-gated sidebar; the sticky value is applied on the next
+ // render.
+ const [cached, setCached] = useState(undefined);
+ useEffect(() => {
+ setCached(readCachedAgentsApiEnabled());
+ }, []);
+
+ // Persist every definitive answer so a cold start during an /api/features
+ // outage can fall back to it instead of failing open and re-introducing the
+ // 403 storm (#3757).
+ useEffect(() => {
+ if (data !== undefined) {
+ writeCachedAgentsApiEnabled(data);
+ setCached(data);
+ }
+ }, [data]);
+
+ // A live answer wins; otherwise stay on the last-known value (sticky) and
+ // only fail open when nothing has ever been observed.
+ return {
+ enabled: resolveAgentsApiEnabled(data, cached),
+ isLoading: isPending,
+ };
+}
+
export function useAgents() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents"],
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index b8d9e75be..fec836dce 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -177,6 +177,7 @@ export const enUS: Translations = {
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
+ agentsDisabledTooltip: "Feature not enabled",
},
// Agents
@@ -188,6 +189,9 @@ export const enUS: Translations = {
emptyTitle: "No custom agents yet",
emptyDescription:
"Create your first custom agent with a specialized system prompt.",
+ featureDisabledTitle: "Agents feature is not enabled",
+ featureDisabledDescription:
+ "This feature is not enabled on this server. Please contact your administrator.",
chat: "Chat",
delete: "Delete",
deleteConfirm:
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index dd68dc6e1..c39641e55 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -119,6 +119,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
+ agentsDisabledTooltip: string;
channels: string;
};
@@ -129,6 +130,8 @@ export interface Translations {
newAgent: string;
emptyTitle: string;
emptyDescription: string;
+ featureDisabledTitle: string;
+ featureDisabledDescription: string;
chat: string;
delete: string;
deleteConfirm: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 3201d6b89..d77b3c2b8 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -170,6 +170,7 @@ export const zhCN: Translations = {
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
+ agentsDisabledTooltip: "功能未启用",
},
// Agents
@@ -179,6 +180,8 @@ export const zhCN: Translations = {
newAgent: "新建智能体",
emptyTitle: "还没有自定义智能体",
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
+ featureDisabledTitle: "智能体功能未启用",
+ featureDisabledDescription: "该功能未在此服务器上启用,请联系管理员。",
chat: "对话",
delete: "删除",
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
diff --git a/frontend/tests/e2e/agents-feature-disabled.spec.ts b/frontend/tests/e2e/agents-feature-disabled.spec.ts
new file mode 100644
index 000000000..f917a869f
--- /dev/null
+++ b/frontend/tests/e2e/agents-feature-disabled.spec.ts
@@ -0,0 +1,93 @@
+import { expect, test } from "@playwright/test";
+
+import { mockLangGraphAPI } from "./utils/mock-api";
+
+test.describe("Agents feature disabled", () => {
+ test("shows disabled message and issues no /api/agents requests when feature is off", async ({
+ page,
+ }) => {
+ // Track any request to the agents API — there should be none. Anchor the
+ // match so it only catches the real agents routes (/api/agents,
+ // /api/agents/check, /api/agents/{name}) and never a future unrelated
+ // path that merely contains the substring.
+ const AGENTS_API = /\/api\/agents(\/|$)/;
+ const agentRequests: string[] = [];
+ page.on("request", (req) => {
+ if (AGENTS_API.test(new URL(req.url()).pathname)) {
+ agentRequests.push(req.url());
+ }
+ });
+
+ // Shell/auth endpoints + the agents API mock (which should never be hit).
+ mockLangGraphAPI(page, { agents: [] });
+
+ // Feature flag reports the agents API as disabled.
+ await page.route("**/api/features", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ agents_api: { enabled: false } }),
+ }),
+ );
+
+ await page.goto("/workspace/agents");
+
+ // The disabled message renders and directs the user to an administrator
+ // (en-US or zh-CN copy) without leaking backend config details.
+ await expect(
+ page.getByText(/contact your administrator|联系管理员/i),
+ ).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText(/config\.yaml|agents_api/i)).toHaveCount(0);
+
+ // Gate prevented every agents API call, including direct navigation.
+ expect(agentRequests).toEqual([]);
+ });
+
+ test("stays disabled (no 403 storm) when /api/features goes down after a known-disabled result", async ({
+ page,
+ }) => {
+ const AGENTS_API = /\/api\/agents(\/|$)/;
+ const agentRequests: string[] = [];
+ page.on("request", (req) => {
+ if (AGENTS_API.test(new URL(req.url()).pathname)) {
+ agentRequests.push(req.url());
+ }
+ });
+
+ mockLangGraphAPI(page, { agents: [] });
+
+ // /api/features first reports disabled, then starts failing — simulating
+ // an outage of the features endpoint after the flag is already known.
+ let featuresUp = true;
+ await page.route("**/api/features", (route) =>
+ featuresUp
+ ? route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ agents_api: { enabled: false } }),
+ })
+ : route.fulfill({
+ status: 500,
+ contentType: "application/json",
+ body: "{}",
+ }),
+ );
+
+ // First visit observes a definitive "disabled" and persists it.
+ await page.goto("/workspace/agents");
+ await expect(
+ page.getByText(/contact your administrator|联系管理员/i),
+ ).toBeVisible({ timeout: 15_000 });
+
+ // The features endpoint now fails. A reload must NOT fail open and remount
+ // the agents page (which would re-trigger the 403 storm of #3757); the
+ // last-known "disabled" value is sticky.
+ featuresUp = false;
+ agentRequests.length = 0;
+ await page.goto("/workspace/agents");
+ await expect(
+ page.getByText(/contact your administrator|联系管理员/i),
+ ).toBeVisible({ timeout: 15_000 });
+ expect(agentRequests).toEqual([]);
+ });
+});
diff --git a/frontend/tests/e2e/sidebar.spec.ts b/frontend/tests/e2e/sidebar.spec.ts
index 6bccf8f73..15a8c19b8 100644
--- a/frontend/tests/e2e/sidebar.spec.ts
+++ b/frontend/tests/e2e/sidebar.spec.ts
@@ -30,6 +30,50 @@ test.describe("Sidebar navigation", () => {
await expect(page).toHaveURL(/\/workspace\/agents/);
});
+ test("Agents button is disabled with a hover tooltip when agents_api is off", async ({
+ page,
+ }) => {
+ mockLangGraphAPI(page);
+ await page.route("**/api/features", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ agents_api: { enabled: false } }),
+ }),
+ );
+
+ await page.goto("/workspace/chats/new");
+
+ const sidebar = page.locator("[data-sidebar='sidebar']");
+ // Chats remains a real link; Agents is no longer a navigable link.
+ await expect(sidebar.locator("a[href='/workspace/chats']")).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(sidebar.locator("a[href='/workspace/agents']")).toHaveCount(0);
+
+ // The disabled Agents button is rendered and announces its disabled state.
+ const agentsButton = sidebar.getByRole("button", { name: "Agents" });
+ await expect(agentsButton).toHaveAttribute("aria-disabled", "true");
+
+ // The button itself has pointer-events suppressed; force the hover so the
+ // event reaches the wrapping tooltip-trigger span that surfaces the tooltip.
+ await agentsButton.hover({ force: true });
+ await expect(page.getByText("Feature not enabled").first()).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Keyboard/screen-reader users get the reason too: the disabled entry
+ // stays in the tab order (focusable) and is wired to a visually-hidden
+ // description rather than relying on the hover-only tooltip.
+ const describedById = await agentsButton.getAttribute("aria-describedby");
+ expect(describedById).toBeTruthy();
+ await expect(page.locator(`#${describedById}`)).toHaveText(
+ "Feature not enabled",
+ );
+ await agentsButton.focus();
+ await expect(agentsButton).toBeFocused();
+ });
+
test("mobile welcome layout stays within viewport and opens sidebar", async ({
page,
}) => {
diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts
index 18acde50e..804a7a5b7 100644
--- a/frontend/tests/e2e/utils/mock-api.ts
+++ b/frontend/tests/e2e/utils/mock-api.ts
@@ -475,6 +475,20 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
+ // Feature flags — frontend gates UI (e.g. agents) on these. Default to
+ // enabled so existing tests exercise the normal path; tests that need the
+ // disabled state override this route after calling mockLangGraphAPI.
+ void page.route("**/api/features", (route) => {
+ if (route.request().method() === "GET") {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ agents_api: { enabled: true } }),
+ });
+ }
+ return route.fallback();
+ });
+
// Skills list — settings page and slash autocomplete
void page.route("**/api/skills", (route) => {
if (route.request().method() === "GET") {
diff --git a/frontend/tests/unit/core/agents/feature-cache.test.ts b/frontend/tests/unit/core/agents/feature-cache.test.ts
new file mode 100644
index 000000000..ce276af0f
--- /dev/null
+++ b/frontend/tests/unit/core/agents/feature-cache.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, test } from "@rstest/core";
+
+import {
+ readCachedAgentsApiEnabled,
+ resolveAgentsApiEnabled,
+ writeCachedAgentsApiEnabled,
+} from "@/core/agents/feature-cache";
+
+describe("resolveAgentsApiEnabled", () => {
+ test("a live value always wins over the cache", () => {
+ expect(resolveAgentsApiEnabled(true, false)).toBe(true);
+ expect(resolveAgentsApiEnabled(false, true)).toBe(false);
+ });
+
+ test("falls back to the cached value when live is unknown (sticky)", () => {
+ // Disabled stays disabled during an /api/features outage, so the 403
+ // storm (#3757) does not come back.
+ expect(resolveAgentsApiEnabled(undefined, false)).toBe(false);
+ expect(resolveAgentsApiEnabled(undefined, true)).toBe(true);
+ });
+
+ test("fails open only when nothing has ever been observed", () => {
+ expect(resolveAgentsApiEnabled(undefined, undefined)).toBe(true);
+ });
+});
+
+describe("agents_api feature cache persistence", () => {
+ const store = new Map();
+ const fakeWindow = {
+ localStorage: {
+ getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
+ setItem: (k: string, v: string) => {
+ store.set(k, v);
+ },
+ removeItem: (k: string) => {
+ store.delete(k);
+ },
+ },
+ };
+
+ afterEach(() => {
+ store.clear();
+ delete (globalThis as { window?: unknown }).window;
+ });
+
+ test("round-trips a persisted value", () => {
+ (globalThis as { window?: unknown }).window = fakeWindow;
+ writeCachedAgentsApiEnabled(false);
+ expect(readCachedAgentsApiEnabled()).toBe(false);
+ writeCachedAgentsApiEnabled(true);
+ expect(readCachedAgentsApiEnabled()).toBe(true);
+ });
+
+ test("returns undefined when nothing is stored", () => {
+ (globalThis as { window?: unknown }).window = fakeWindow;
+ expect(readCachedAgentsApiEnabled()).toBeUndefined();
+ });
+
+ test("no-ops without a browser environment (SSR)", () => {
+ // window is undefined in the node test environment.
+ expect(readCachedAgentsApiEnabled()).toBeUndefined();
+ expect(() => writeCachedAgentsApiEnabled(true)).not.toThrow();
+ });
+});
diff --git a/frontend/tests/unit/core/agents/features.test.ts b/frontend/tests/unit/core/agents/features.test.ts
new file mode 100644
index 000000000..a0245c8e6
--- /dev/null
+++ b/frontend/tests/unit/core/agents/features.test.ts
@@ -0,0 +1,47 @@
+import { beforeEach, describe, expect, test, rs } from "@rstest/core";
+
+rs.mock("@/core/api/fetcher", () => ({
+ fetch: rs.fn(),
+}));
+
+rs.mock("@/core/config", () => ({
+ getBackendBaseURL: () => "",
+}));
+
+import { fetchAgentsApiEnabled } from "@/core/agents/api";
+import { fetch as fetcher } from "@/core/api/fetcher";
+
+const mockedFetch = rs.mocked(fetcher);
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ mockedFetch.mockReset();
+});
+
+describe("fetchAgentsApiEnabled", () => {
+ test("returns true when backend reports agents_api enabled", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, { agents_api: { enabled: true } }),
+ );
+ await expect(fetchAgentsApiEnabled()).resolves.toBe(true);
+ expect(mockedFetch).toHaveBeenCalledWith("/api/features");
+ });
+
+ test("returns false when backend reports agents_api disabled", async () => {
+ mockedFetch.mockResolvedValueOnce(
+ jsonResponse(200, { agents_api: { enabled: false } }),
+ );
+ await expect(fetchAgentsApiEnabled()).resolves.toBe(false);
+ });
+
+ test("throws when the features request fails", async () => {
+ mockedFetch.mockResolvedValueOnce(jsonResponse(500, {}));
+ await expect(fetchAgentsApiEnabled()).rejects.toThrow();
+ });
+});