mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
* feat(gateway): add GET /api/features for frontend feature gating (#3757) * feat(agents): add useAgentsApiEnabled feature-flag hook (#3757) * feat(agents): gate /workspace/agents segment on agents_api flag (#3757) * feat(sidebar): grey out Agents button with tooltip when agents_api disabled (#3757) * fix(sidebar): show disabled Agents tooltip on hover, harden a11y + e2e (#3757) - Wrap the disabled Agents button in a hoverable tooltip trigger so the 'feature not enabled' hint shows in the expanded sidebar, not only when collapsed (the SidebarMenuButton tooltip prop is hidden unless collapsed). - Add tabIndex={-1} and drop the redundant onClick preventDefault. - Add e2e coverage for the disabled state + a default /api/features mock. * style(sidebar): move cursor-not-allowed to the hoverable span (#3757) * test(agents): anchor e2e agents-API request filter with a path regex (#3757) * fix(agents): don't expose backend config in disabled message; tell user to contact admin (#3757) The disabled panel previously said 'Set agents_api.enabled: true in config.yaml', leaking backend configuration to end users. Replace with a generic 'not enabled on this server, contact your administrator' message (matching the existing nameStepApiDisabledError copy). e2e now asserts the contact-admin message and that no config.yaml/agents_api text is rendered. * make format * fix(agents): keep agents_api flag sticky during /api/features outage (#3757) Failing open re-mounted the agents UI and re-triggered the 403 storm when agents_api was genuinely disabled and /api/features was down. Persist the last definitive answer and fall back to it (sticky) before failing open, only failing open when nothing has ever been observed. Read the cached value after mount so the first client render matches the server (no hydration mismatch on the non-loading-gated sidebar). * fix(sidebar): make disabled Agents entry keyboard/SR accessible (#3757) The disabled-state explanation was hover-only: tabIndex={-1} removed the entry from the tab order and the reason lived only in a pointer-triggered tooltip. Keep it in the tab order and wire aria-describedby to a visually-hidden reason so keyboard and screen-reader users learn why it is disabled. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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}}
|
||||
@@ -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 (
|
||||
<div className="text-muted-foreground flex size-full items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return <AgentsFeatureDisabled />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { BotOffIcon } from "lucide-react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function AgentsFeatureDisabled() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotOffIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.featureDisabledTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-sm">
|
||||
{t.agents.featureDisabledDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SidebarGroup className="pt-1">
|
||||
<SidebarMenu>
|
||||
@@ -27,15 +34,46 @@ export function WorkspaceNavChatList() {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-muted-foreground" href="/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{agentsEnabled ? (
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-muted-foreground" href="/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
// 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.
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* cursor-not-allowed lives on the span (the element that
|
||||
still receives pointer events), not the inert button. */}
|
||||
<span className="block w-full cursor-not-allowed">
|
||||
<SidebarMenuButton
|
||||
className="text-muted-foreground/50"
|
||||
aria-disabled
|
||||
aria-describedby="agents-disabled-reason"
|
||||
>
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</SidebarMenuButton>
|
||||
<span id="agents-disabled-reason" className="sr-only">
|
||||
{t.sidebar.agentsDisabledTooltip}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t.sidebar.agentsDisabledTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -87,6 +87,19 @@ export async function deleteAgent(name: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
}
|
||||
|
||||
interface FeaturesResponse {
|
||||
agents_api: { enabled: boolean };
|
||||
}
|
||||
|
||||
export async function fetchAgentsApiEnabled(): Promise<boolean> {
|
||||
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 }> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<boolean | undefined>(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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "确定要删除该智能体吗?此操作不可撤销。",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string, string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user