From 65fa321ec18a8e97b5ee60b023afbd95b0eba83b Mon Sep 17 00:00:00 2001 From: PeaceMaker-best Date: Mon, 12 Oct 2026 19:00:54 +0800 Subject: [PATCH] feat(deer-flow): configure agent tool access (#6630) * feat(deer-flow): configure agent tool access Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> * fix(frontend): reconcile knowledge defaults with agent tool access * docs: reduce frontend agent guidance below size limit * fix: address agent tool-group catalog review findings --------- Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: Willem Jiang --- README.md | 9 + backend/app/gateway/routers/AGENTS.md | 4 + backend/app/gateway/routers/agents.py | 32 ++- .../tests/test_agent_tool_groups_router.py | 103 ++++++++ frontend/AGENTS.md | 12 + frontend/src/AGENTS.md | 14 +- .../agents/agent-capability-selection.tsx | 75 ++++++ .../agents/agent-settings-dialog.tsx | 37 ++- frontend/src/core/agents/api.ts | 9 + frontend/src/core/agents/hooks.ts | 9 + frontend/src/core/i18n/locales/en-US.ts | 8 + frontend/src/core/i18n/locales/types.ts | 6 + frontend/src/core/i18n/locales/zh-CN.ts | 6 + .../agent-capability-selection.dom.test.tsx | 109 +++++++++ .../agents/agent-display-name.dom.test.tsx | 65 +++++ .../agent-tool-groups-knowledge.dom.test.tsx | 226 ++++++++++++++++++ 16 files changed, 710 insertions(+), 14 deletions(-) create mode 100644 backend/tests/test_agent_tool_groups_router.py create mode 100644 frontend/tests/unit/components/workspace/agents/agent-capability-selection.dom.test.tsx create mode 100644 frontend/tests/unit/components/workspace/agents/agent-tool-groups-knowledge.dom.test.tsx diff --git a/README.md b/README.md index 2bdb6e914b..f708371b6e 100644 --- a/README.md +++ b/README.md @@ -1657,6 +1657,15 @@ changed, or cleared, including unscoped runs accepted before this feature. This default applies to Gateway-hosted custom-agent turns; direct harness/client integrations continue to supply their own execution scope. +In Agent settings, **Tool access** can inherit every configured tool group, +allow selected groups, or disable all groups. Saved groups stay visible and +removable while the catalog loads or fails to load. A saved group is marked +unavailable only when a successful catalog load confirms it is missing. +Saving a selection that removes `knowledge` also clears the saved default +knowledge selection. When enabling knowledge access for an agent that +previously lacked it, save the tool access first, then reopen settings to +choose its default knowledge bases. + The `knowledge_base` block is provider-neutral and only controls whether the knowledge capability and selector are enabled. RAGFlow connection, dataset allowlist, and retrieval parameters (`base_url`, `api_key`, `datasets`, diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index cb3f9e6564..58df58187a 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -16,6 +16,10 @@ create-only: collisions return 409, never overwrite or upsert. Ordinary creation and import share `_persist_new_agent`, which uses `run_drained_write` so a cancelled request still drains the owned persistence worker. +`GET /api/agent-tool-groups` projects only configured group names for the +custom-Agent settings allowlist. Never expose tool provider paths, credentials, +or raw tool configuration through this catalog. + Custom Agent update guards apply legacy shared-file checks only when the resolved store is `FileAgentStore`, including the `update_agent` tool. DB imports retain source files for rollback; those files cannot veto SQL updates. diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 5415de97b9..e831f3b266 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -6,10 +6,11 @@ import re from pathlib import Path from typing import Literal -from fastapi import APIRouter, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, ConfigDict, Field from app.gateway.authz import require_permission +from app.gateway.deps import get_config from app.gateway.persistent_writes import run_drained_write from deerflow.agents.memory.manager import get_memory_manager from deerflow.config.agents_api_config import get_agents_api_config @@ -22,7 +23,7 @@ from deerflow.config.agents_config import ( load_agent_soul, preserve_non_managed_fields, ) -from deerflow.config.app_config import get_app_config +from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.paths import get_paths from deerflow.knowledge_scope import KnowledgeScope, canonicalize_knowledge_scope from deerflow.persistence.agents import AgentDeleteOutcome, AgentExistsError, get_agent_store @@ -67,6 +68,12 @@ class AgentsListResponse(BaseModel): agents: list[AgentResponse] +class ToolGroupsResponse(BaseModel): + """Configured tool groups safe to present in Agent settings.""" + + tool_groups: list[str] + + class AgentCreateRequest(BaseModel): """Request body for creating a custom agent.""" @@ -267,6 +274,11 @@ def _build_agent_config(body: AgentCreateRequest, normalized_name: str, *, memor return config_data +def _configured_tool_group_names(config: AppConfig) -> list[str]: + """Project configured group names without exposing tool definitions.""" + return list(dict.fromkeys(group.name for group in config.tool_groups)) + + async def _persist_new_agent(body: AgentCreateRequest, normalized_name: str, user_id: str, *, memory_enabled: bool = True) -> AgentResponse: """Atomically create and reload one user-scoped agent.""" config_data = _build_agent_config(body, normalized_name, memory_enabled=memory_enabled) @@ -312,6 +324,22 @@ async def list_agents(request: Request) -> AgentsListResponse: raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") +@router.get( + "/agent-tool-groups", + response_model=ToolGroupsResponse, + summary="List Agent Tool Groups", + description="List configured tool-group names available for custom-agent allowlists.", +) +@require_permission("agents", "read") +async def list_agent_tool_groups( + request: Request, + config: AppConfig = Depends(get_config), +) -> ToolGroupsResponse: + """Return names only; tool implementations and configuration stay private.""" + _require_agents_api_enabled() + return ToolGroupsResponse(tool_groups=_configured_tool_group_names(config)) + + @router.get( "/agents/check", summary="Check Agent Name", diff --git a/backend/tests/test_agent_tool_groups_router.py b/backend/tests/test_agent_tool_groups_router.py new file mode 100644 index 0000000000..0b86dfc4a0 --- /dev/null +++ b/backend/tests/test_agent_tool_groups_router.py @@ -0,0 +1,103 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.gateway.auth.models import User +from app.gateway.authz import AuthContext, Permissions +from app.gateway.deps import get_config +from app.gateway.routers.agents import ( + ToolGroupsResponse, + _configured_tool_group_names, + router, +) +from deerflow.config.agents_api_config import AgentsApiConfig +from deerflow.config.app_config import AppConfig +from deerflow.config.sandbox_config import SandboxConfig +from deerflow.config.tool_config import ToolGroupConfig + + +@pytest.fixture +def tool_group_config() -> AppConfig: + return AppConfig( + sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"), + tool_groups=[ + ToolGroupConfig(name="web"), + ToolGroupConfig(name="file:read"), + ToolGroupConfig(name="web"), + ToolGroupConfig(name="knowledge"), + ], + ) + + +@pytest.fixture +def tool_group_client(monkeypatch: pytest.MonkeyPatch, tool_group_config: AppConfig): + api_config = AgentsApiConfig(enabled=True) + auth = AuthContext( + user=User(email="tool-groups@example.com", password_hash="test-only"), + permissions=[Permissions.AGENTS_READ], + ) + monkeypatch.setattr("app.gateway.routers.agents.get_agents_api_config", lambda: api_config) + monkeypatch.setattr("app.gateway.authz._authenticate", AsyncMock(return_value=auth)) + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_config] = lambda: tool_group_config + with TestClient(app) as client: + yield client, auth, api_config + + +def test_tool_group_catalog_preserves_operator_order_and_deduplicates(tool_group_config: AppConfig) -> None: + response = ToolGroupsResponse(tool_groups=_configured_tool_group_names(tool_group_config)) + + assert response.model_dump() == { + "tool_groups": ["web", "file:read", "knowledge"], + } + + +def test_tool_group_catalog_does_not_reserve_a_valid_agent_name() -> None: + paths = {route.path for route in router.routes} + + assert "/api/agent-tool-groups" in paths + assert "/api/agents/tool-groups" not in paths + + +def test_tool_group_catalog_returns_configured_names_over_http(tool_group_client) -> None: + client, _, _ = tool_group_client + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 200 + assert response.json() == {"tool_groups": ["web", "file:read", "knowledge"]} + + +def test_tool_group_catalog_rejects_disabled_agents_api(tool_group_client) -> None: + client, _, api_config = tool_group_client + api_config.enabled = False + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 403 + assert "Custom-agent management API is disabled" in response.json()["detail"] + + +@pytest.mark.parametrize("permissions", [[], [Permissions.AGENTS_WRITE]]) +def test_tool_group_catalog_requires_agents_read_permission(tool_group_client, permissions: list[str]) -> None: + client, auth, _ = tool_group_client + auth.permissions = permissions + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 403 + assert response.json() == {"detail": "Permission denied: agents:read"} + + +def test_tool_group_catalog_requires_authentication(tool_group_client) -> None: + client, auth, _ = tool_group_client + auth.user = None + + response = client.get("/api/agent-tool-groups") + + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 28214af08e..9a7fd27d11 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -109,6 +109,18 @@ keep `name` for React identity, URLs, requests, and runtime `agent_name`. The 100-code-point budget uses `[...value.trim()].length`, matching Pydantic; do not use HTML `maxLength`, which counts UTF-16 code units instead. +Custom Agent tool access is edited from the configured names returned by +`GET /api/agent-tool-groups`. Preserve the `null` / `[]` / explicit-list +contract (inherit all / disable all / allowlist), retain missing selected names +as removable entries, and submit `tool_groups` only when the user changes it. +Mark missing names unavailable only after a successful catalog load; loading +and errors preserve neutral labels and removable selections. +When a changed allowlist excludes `knowledge`, send `knowledge_scope: null` in +the same update so a saved retrieval default cannot break run admission. Gate +knowledge selection on both draft and persisted tool access: catalog reads +authorize against the saved agent, so new access must be saved first. Restored +tool selections remain unchanged and must not clear a knowledge default. + Custom Agent portability uses the versioned `deerflow.custom-agent` JSON document through `core/agents/api.ts`. Keep file parsing client-side only for previewing the proposed local name; the Gateway is authoritative for schema, diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 5a8e90e092..6418ad7b7e 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -232,13 +232,13 @@ Markdown. Source records retain retrieval-time evidence, not live documents. Resolve knowledge destinations before testing the label in message and artifact link renderers: Sources lists use ordinary titles without a `citation:` prefix. -Custom-agent settings persist `knowledge_scope` as an optional retrieval default. -Use `knowledgeScopeToSelection` to restore all execution IDs, falling back to IDs -where the bounded display snapshot lacks names. Only dirty knowledge settings -are sent on save. Custom-agent chat derives its initial selection from the saved -binding; explicit page-local overrides survive new-thread route replacement and -reset on conversation changes. Gateway supplies defaults for clients without a -selector; frontend visibility must not become a runtime enforcement boundary. +Custom-agent settings store an optional `knowledge_scope` retrieval default. +`knowledgeScopeToSelection` restores all execution IDs; missing display names +fall back to IDs. Save only edited scopes, except removing knowledge tool access +must atomically clear the default. Catalog selection needs saved and draft access. +Chats initialize from saved bindings; explicit page-local overrides survive +new-thread route replacement and reset on conversation changes. Gateway supplies +defaults for clients without a selector; UI visibility is not runtime enforcement. MCP hooks and the editor default to personal scope. Mutation-option builders require an explicit scope. MCP control accessible names include the localized ownership section, so same-named shared and personal connections remain distinct without region context. Query keys include scope and authenticated user ID; remount each editor when that ID changes. Platform provided uses the deployment editor only for administrators outside static mode, restoring add, edit, toggle and delete through the existing admin-only MCP API. Ordinary users see shared entries read-only. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured. diff --git a/frontend/src/components/workspace/agents/agent-capability-selection.tsx b/frontend/src/components/workspace/agents/agent-capability-selection.tsx index 588c61054c..22a6095f1a 100644 --- a/frontend/src/components/workspace/agents/agent-capability-selection.tsx +++ b/frontend/src/components/workspace/agents/agent-capability-selection.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAgentToolGroups } from "@/core/agents"; import { capabilityCopy } from "@/core/capabilities/copy"; import { useCapabilityInstallations } from "@/core/capabilities/hooks"; import { useI18n } from "@/core/i18n/hooks"; @@ -71,16 +72,89 @@ function Selection({ ); } + +function ToolGroupSelection({ + value, + onChange, +}: { + value: string[] | null; + onChange: (value: string[] | null) => void; +}) { + const { t } = useI18n(); + const { toolGroups, isLoading, error } = useAgentToolGroups(); + const options = new Map(toolGroups.map((name) => [name, name])); + for (const name of value ?? []) { + if (!options.has(name)) { + options.set( + name, + !isLoading && !error + ? `${name} (${t.agents.settingsToolGroupUnavailable})` + : name, + ); + } + } + + return ( +
+ + {t.agents.settingsToolGroups} + +

+ {t.agents.settingsToolGroupsHint} +

+ + {isLoading && ( +

{t.common.loading}

+ )} + {error && ( +

+ {t.agents.settingsToolGroupsLoadFailed} +

+ )} + {value !== null && ( +
+ {[...options].map(([name, label]) => ( + + ))} +
+ )} +
+ ); +} export function AgentCapabilitySelection({ plugins, skills, + toolGroups, onPluginsChange, onSkillsChange, + onToolGroupsChange, }: { plugins: string[] | null; skills: string[] | null; + toolGroups: string[] | null; onPluginsChange: (value: string[] | null) => void; onSkillsChange: (value: string[] | null) => void; + onToolGroupsChange: (value: string[] | null) => void; }) { const { locale } = useI18n(); const copy = capabilityCopy(locale); @@ -92,6 +166,7 @@ export function AgentCapabilitySelection({

{copy.hint}

+ ); } diff --git a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx index 002fca73ee..d00912734a 100644 --- a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx +++ b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx @@ -97,11 +97,27 @@ export function AgentSettingsDialog({ const [initialSelections] = useState(() => ({ plugins: agent.mcp_plugins ?? null, skills: agent.skills ?? null, + toolGroups: agent.tool_groups ?? null, })); const [plugins, setPlugins] = useState( agent.mcp_plugins ?? null, ); const [skills, setSkills] = useState(agent.skills ?? null); + const [toolGroups, setToolGroups] = useState( + agent.tool_groups ?? null, + ); + const toolGroupsChanged = !sameSelection( + toolGroups, + initialSelections.toolGroups, + ); + const knowledgeAllowed = + toolGroups === null || toolGroups.includes("knowledge"); + const clearKnowledgeDefault = toolGroupsChanged && !knowledgeAllowed; + // Catalog reads authorize against saved groups, never the dialog's draft. + const knowledgeAccessNeedsSave = + knowledgeAllowed && + agent.tool_groups != null && + !agent.tool_groups.includes("knowledge"); const [displayName, setDisplayName] = useState(agent.display_name ?? ""); const [model, setModel] = useState(agent.model ?? DEFAULT_MODEL_VALUE); @@ -194,9 +210,9 @@ export function AgentSettingsDialog({ name: agent.name, request: { display_name: displayName.trim() || null, - ...(knowledgeChanged && { + ...((knowledgeChanged || clearKnowledgeDefault) && { knowledge_scope: - knowledgeSelection.mode === "all" + clearKnowledgeDefault || knowledgeSelection.mode === "all" ? null : buildKnowledgeScopeSnapshot(knowledgeSelection), }), @@ -204,6 +220,9 @@ export function AgentSettingsDialog({ mcp_plugins: plugins, }), ...(!sameSelection(skills, initialSelections.skills) && { skills }), + ...(toolGroupsChanged && { + tool_groups: toolGroups, + }), model: model === DEFAULT_MODEL_VALUE ? null : model, model_settings: parsedSettings.modelSettings, thinking_enabled: supportsThinking @@ -239,8 +258,10 @@ export function AgentSettingsDialog({ {(scopeSelectionEnabled || agent.knowledge_scope) && (
@@ -259,16 +280,22 @@ export function AgentSettingsDialog({ unavailableReason={ !scopeSelectionEnabled ? t.knowledge.scope.loadFailed - : agent.tool_groups != null && - !agent.tool_groups.includes("knowledge") + : !knowledgeAllowed ? t.knowledge.scope.agentUnavailable - : undefined + : knowledgeAccessNeedsSave + ? t.agents.settingsKnowledgeSaveToolAccess + : undefined } onChange={(selection) => { setKnowledgeSelection(selection); setKnowledgeChanged(true); }} /> + {scopeSelectionEnabled && knowledgeAccessNeedsSave && ( +

+ {t.agents.settingsKnowledgeSaveToolAccess} +

+ )} {knowledgeSelection.mode === "selected" && (

{knowledgeSelection.datasets diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts index 89d2997c7b..01a718adb4 100644 --- a/frontend/src/core/agents/api.ts +++ b/frontend/src/core/agents/api.ts @@ -48,6 +48,15 @@ export async function listAgents(): Promise { return data.agents; } +export async function listAgentToolGroups(): Promise { + const res = await fetch(`${getBackendBaseURL()}/api/agent-tool-groups`); + if (!res.ok) { + throw new Error(`Failed to load agent tool groups: ${res.statusText}`); + } + const data = (await res.json()) as { tool_groups: string[] }; + return data.tool_groups; +} + export async function getAgent(name: string): Promise { const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`); if (!res.ok) throw new Error(`Agent '${name}' not found`); diff --git a/frontend/src/core/agents/hooks.ts b/frontend/src/core/agents/hooks.ts index f6d5a42b2b..fcfd6f0814 100644 --- a/frontend/src/core/agents/hooks.ts +++ b/frontend/src/core/agents/hooks.ts @@ -7,6 +7,7 @@ import { fetchAgentsApiEnabled, getAgent, importAgentPackage, + listAgentToolGroups, listAgents, updateAgent, } from "./api"; @@ -64,6 +65,14 @@ export function useAgents() { return { agents: data ?? [], isLoading, error }; } +export function useAgentToolGroups() { + const { data, isLoading, error } = useQuery({ + queryKey: ["agent-tool-groups"], + queryFn: () => listAgentToolGroups(), + }); + return { toolGroups: data ?? [], isLoading, error }; +} + export function useAgent(name: string | null | undefined) { const { data, isLoading, error } = useQuery({ queryKey: ["agents", name], diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 712d0f3a18..af1587d171 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1360,12 +1360,20 @@ export const enUS: Translations = { "Display name must be at most 100 Unicode code points.", settingsDisplayNameHint: "Supports Unicode. Leave blank to use the agent identifier", + settingsToolGroups: "Tool access", + settingsToolGroupsHint: + "Restrict which configured tool groups this agent and its delegated tasks may use.", + settingsToolGroupsAll: "Use every configured tool group", + settingsToolGroupsLoadFailed: "Tool groups could not be loaded.", + settingsToolGroupUnavailable: "unavailable", settingsDescription: "Choose a display name and model defaults for this agent. Model changes take effect on the next message.", settingsKnowledge: "Default knowledge", settingsKnowledgeHint: "New conversations use this knowledge scope. Individual messages can override it. Selecting all knowledge bases removes the binding.", settingsKnowledgeReset: "Use all knowledge bases", + settingsKnowledgeSaveToolAccess: + "Save tool access before choosing default knowledge bases.", settingsModel: "Default model", settingsModelDefault: "Use global default", settingsTemperature: "Temperature", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index f3c563e66a..e1e8abce16 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -1119,10 +1119,16 @@ export interface Translations { settingsDisplayName: string; settingsDisplayNameTooLong: string; settingsDisplayNameHint: string; + settingsToolGroups: string; + settingsToolGroupsHint: string; + settingsToolGroupsAll: string; + settingsToolGroupsLoadFailed: string; + settingsToolGroupUnavailable: string; settingsDescription: string; settingsKnowledge: string; settingsKnowledgeHint: string; settingsKnowledgeReset: string; + settingsKnowledgeSaveToolAccess: string; settingsModel: string; settingsModelDefault: string; settingsTemperature: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index f5d187cd37..963bc6f64d 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1274,12 +1274,18 @@ export const zhCN: Translations = { settingsDisplayName: "显示名称", settingsDisplayNameTooLong: "显示名称不能超过 100 个 Unicode 码点。", settingsDisplayNameHint: "支持中文等 Unicode 字符,留空时使用智能体标识", + settingsToolGroups: "工具权限", + settingsToolGroupsHint: "限制该智能体及其委派任务可以使用的已配置工具组。", + settingsToolGroupsAll: "使用全部已配置工具组", + settingsToolGroupsLoadFailed: "无法加载工具组。", + settingsToolGroupUnavailable: "不可用", settingsDescription: "为该智能体设置显示名称和默认模型,模型修改在下一条消息生效。", settingsKnowledge: "默认知识库", settingsKnowledgeHint: "新对话默认使用此知识库范围,单条消息可以临时覆盖。选择全部知识库可解除绑定。", settingsKnowledgeReset: "使用全部知识库", + settingsKnowledgeSaveToolAccess: "请先保存工具权限,再选择默认知识库。", settingsModel: "默认模型", settingsModelDefault: "使用全局默认", settingsTemperature: "温度", diff --git a/frontend/tests/unit/components/workspace/agents/agent-capability-selection.dom.test.tsx b/frontend/tests/unit/components/workspace/agents/agent-capability-selection.dom.test.tsx new file mode 100644 index 0000000000..4ba7c841f9 --- /dev/null +++ b/frontend/tests/unit/components/workspace/agents/agent-capability-selection.dom.test.tsx @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +import { AgentCapabilitySelection } from "@/components/workspace/agents/agent-capability-selection"; +import { enUS } from "@/core/i18n/locales/en-US"; + +const { useAgentToolGroups } = rs.hoisted(() => ({ + useAgentToolGroups: rs.fn(), +})); +rs.mock("@/core/agents", () => ({ useAgentToolGroups })); +rs.mock("@/core/capabilities/hooks", () => ({ + useCapabilityInstallations: () => ({ + data: { items: [] }, + isLoading: false, + isError: false, + }), +})); +rs.mock("@/core/i18n/hooks", () => ({ + useI18n: () => ({ locale: "en-US", t: enUS }), +})); + +afterEach(() => { + cleanup(); + useAgentToolGroups.mockReset(); +}); + +function selection(onToolGroupsChange = rs.fn()) { + return ( + + ); +} + +describe("tool-group catalog availability", () => { + it.each([ + { state: "loading", isLoading: true, error: null }, + { state: "failed", isLoading: false, error: new Error("Load failed") }, + ])("keeps saved groups neutral and removable when $state", (query) => { + useAgentToolGroups.mockReturnValue({ toolGroups: [], ...query }); + const onChange = rs.fn(); + render(selection(onChange)); + + for (const name of ["web", "legacy-tools"]) { + const checkbox = screen.getByLabelText(name); + expect(checkbox.checked).toBe(true); + expect(checkbox.disabled).toBe(false); + } + expect(screen.queryByText(/\(unavailable\)/)).toBeNull(); + if (query.isLoading) { + expect(screen.getByText(enUS.common.loading)).toBeTruthy(); + } else { + expect(screen.getByRole("alert").textContent).toBe( + enUS.agents.settingsToolGroupsLoadFailed, + ); + } + + fireEvent.click(screen.getByLabelText("legacy-tools")); + expect(onChange).toHaveBeenCalledWith(["web"]); + }); + + it("marks only missing groups unavailable after a successful load", () => { + useAgentToolGroups.mockReturnValue({ + toolGroups: ["web"], + isLoading: false, + error: null, + }); + const onChange = rs.fn(); + render(selection(onChange)); + + expect(screen.getByLabelText("web").checked).toBe(true); + const missing = screen.getByLabelText( + "legacy-tools (unavailable)", + ); + expect(missing.checked).toBe(true); + fireEvent.click(missing); + expect(onChange).toHaveBeenCalledWith(["web"]); + }); + + it("updates availability after loading without changing the selection", () => { + useAgentToolGroups.mockReturnValue({ + toolGroups: [], + isLoading: true, + error: null, + }); + const onChange = rs.fn(); + const { rerender } = render(selection(onChange)); + expect(screen.getByLabelText("legacy-tools")).toBeTruthy(); + + useAgentToolGroups.mockReturnValue({ + toolGroups: ["web"], + isLoading: false, + error: null, + }); + rerender(selection(onChange)); + + expect(screen.getByLabelText("web").checked).toBe(true); + expect( + screen.getByLabelText("legacy-tools (unavailable)") + .checked, + ).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx b/frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx index 472532a86c..63b826abef 100644 --- a/frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/agents/agent-display-name.dom.test.tsx @@ -17,6 +17,11 @@ const { mutateAsync } = rs.hoisted(() => ({ mutateAsync: rs.fn().mockResolvedValue({}), })); rs.mock("@/core/agents", () => ({ + useAgentToolGroups: () => ({ + toolGroups: ["web", "file:read"], + isLoading: false, + error: null, + }), useUpdateAgent: () => ({ mutateAsync, isPending: false }), })); rs.mock("@/core/features", () => ({ @@ -200,3 +205,63 @@ describe("capability selection update isolation", () => { expect(request).not.toHaveProperty("skills"); }); }); + +describe("custom agent tool groups", () => { + it("saves an explicit tool-group allowlist", async () => { + render(); + + fireEvent.click(screen.getByLabelText("Use every configured tool group")); + fireEvent.click(screen.getByLabelText("web")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + const { request } = mutateAsync.mock.calls[0]![0] as { + request: Record; + }; + expect(request.tool_groups).toEqual(["web"]); + }); + + it("keeps unavailable selected groups visible so they can be removed", async () => { + render( + , + ); + + fireEvent.click(screen.getByLabelText("legacy-tools (unavailable)")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + const { request } = mutateAsync.mock.calls[0]![0] as { + request: Record; + }; + expect(request.tool_groups).toEqual([]); + }); + + it("omits untouched tool groups after a concurrent agent refresh", async () => { + const opened = { ...agent, tool_groups: ["web"] }; + const { rerender } = render( + , + ); + rerender( + , + ); + + fireEvent.change(screen.getByLabelText("Display name"), { + target: { value: "Rename only" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + const { request } = mutateAsync.mock.calls[0]![0] as { + request: Record; + }; + expect(request).not.toHaveProperty("tool_groups"); + }); +}); diff --git a/frontend/tests/unit/components/workspace/agents/agent-tool-groups-knowledge.dom.test.tsx b/frontend/tests/unit/components/workspace/agents/agent-tool-groups-knowledge.dom.test.tsx new file mode 100644 index 0000000000..3f05ef3e0f --- /dev/null +++ b/frontend/tests/unit/components/workspace/agents/agent-tool-groups-knowledge.dom.test.tsx @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { PropsWithChildren } from "react"; + +import { AgentSettingsDialog } from "@/components/workspace/agents/agent-settings-dialog"; +import type { Agent } from "@/core/agents"; +import { enUS } from "@/core/i18n/locales/en-US"; +import type { KnowledgeScopeSnapshot } from "@/core/knowledge"; + +const { mutateAsync } = rs.hoisted(() => ({ + mutateAsync: rs.fn().mockResolvedValue({}), +})); +rs.mock("@/core/agents", () => ({ + useAgentToolGroups: () => ({ + toolGroups: ["web", "knowledge", "file:read"], + isLoading: false, + error: null, + }), + useUpdateAgent: () => ({ mutateAsync, isPending: false }), +})); +rs.mock("@/core/features", () => ({ + useKnowledgeBaseEnabled: () => ({ scopeSelectionEnabled: true }), +})); +rs.mock("@/core/models/hooks", () => ({ useModels: () => ({ models: [] }) })); +rs.mock("@/core/subagents", () => ({ + useSubagents: () => ({ subagents: [] }), +})); +rs.mock("@/core/capabilities/hooks", () => ({ + useCapabilityInstallations: () => ({ + data: { items: [], can_manage: false }, + isLoading: false, + isError: false, + }), +})); +rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) })); +rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } })); + +const selectedScope: KnowledgeScopeSnapshot = { + version: 1, + mode: "selected", + dataset_ids: ["policies"], +}; +const agent: Agent = { + name: "researcher", + description: "", + model: null, + tool_groups: ["web", "knowledge"], + skills: null, + knowledge_scope: selectedScope, +}; + +afterEach(() => { + cleanup(); + mutateAsync.mockClear(); + rs.restoreAllMocks(); +}); + +function renderSettings(value: Agent) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + function Wrapper({ children }: PropsWithChildren) { + return ( + {children} + ); + } + return render( + , + { wrapper: Wrapper }, + ); +} + +async function saveRequest() { + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + return ( + mutateAsync.mock.calls[0]![0] as { + request: Record; + } + ).request; +} + +describe("tool access and default knowledge", () => { + it.each([ + ["selected", ["web"], selectedScope], + ["selected", [], selectedScope], + ["disabled", ["web"], { version: 1, mode: "disabled" }], + ["disabled", [], { version: 1, mode: "disabled" }], + ] as const)( + "clears a %s knowledge default atomically when groups become %j", + async (_mode, groups, scope) => { + renderSettings({ ...agent, knowledge_scope: scope }); + fireEvent.click(screen.getByLabelText("knowledge")); + if (groups.length === 0) fireEvent.click(screen.getByLabelText("web")); + + expect(await saveRequest()).toMatchObject({ + tool_groups: groups, + knowledge_scope: null, + }); + }, + ); + + it("clears the default when inherited tool access is disabled", async () => { + renderSettings({ ...agent, tool_groups: null }); + fireEvent.click(screen.getByLabelText("Use every configured tool group")); + + expect(await saveRequest()).toMatchObject({ + tool_groups: [], + knowledge_scope: null, + }); + }); + + it("omits knowledge and groups when a draft restriction is restored", async () => { + renderSettings(agent); + fireEvent.click(screen.getByLabelText("knowledge")); + fireEvent.click(screen.getByLabelText("knowledge")); + + const request = await saveRequest(); + expect(request).not.toHaveProperty("tool_groups"); + expect(request).not.toHaveProperty("knowledge_scope"); + }); + + it("preserves concurrent defaults on a rename-only save", async () => { + const { rerender } = renderSettings(agent); + rerender( + , + ); + fireEvent.change(screen.getByLabelText("Display name"), { + target: { value: "Renamed" }, + }); + + const request = await saveRequest(); + expect(request).not.toHaveProperty("tool_groups"); + expect(request).not.toHaveProperty("knowledge_scope"); + }); + + it("preserves an untouched default when other groups change", async () => { + renderSettings(agent); + fireEvent.click(screen.getByLabelText("web")); + + const request = await saveRequest(); + expect(request.tool_groups).toEqual(["knowledge"]); + expect(request).not.toHaveProperty("knowledge_scope"); + }); + + it.each(["knowledge", "Use every configured tool group"])( + "requires saved access before choosing knowledge after enabling %s", + async (label) => { + const fetch = rs + .spyOn(globalThis, "fetch") + .mockResolvedValue( + Response.json({ items: [], page: 1, page_size: 100, total: 0 }), + ); + renderSettings({ ...agent, tool_groups: ["web"], knowledge_scope: null }); + fireEvent.click(screen.getByLabelText(label)); + + const trigger = screen.getByTestId("knowledge-scope-trigger"); + expect((trigger as HTMLButtonElement).disabled).toBe(true); + expect( + screen.getByText(enUS.agents.settingsKnowledgeSaveToolAccess), + ).toBeTruthy(); + fireEvent.click(trigger); + expect(fetch).not.toHaveBeenCalled(); + expect((await saveRequest()).tool_groups).toEqual( + label === "knowledge" ? ["web", "knowledge"] : null, + ); + }, + ); + + it("loads the catalog after knowledge access is persisted", async () => { + const fetch = rs.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve( + Response.json({ + items: [{ id: "policies", name: "Policies", selectable: true }], + page: 1, + page_size: 100, + total: 1, + }), + ), + ); + const { rerender } = renderSettings({ + ...agent, + tool_groups: ["web"], + knowledge_scope: null, + }); + fireEvent.click(screen.getByLabelText("knowledge")); + rerender( + , + ); + + const trigger = screen.getByTestId("knowledge-scope-trigger"); + expect((trigger as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(trigger); + fireEvent.click( + screen.getByRole("radio", { name: "Selected knowledge bases" }), + ); + await screen.findByText("Policies"); + expect(fetch).toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText("Policies")); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect((await saveRequest()).knowledge_scope).toMatchObject({ + mode: "selected", + dataset_ids: ["policies"], + }); + }); +});