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(); + }); +});