"""Tests for custom agent support.""" from __future__ import annotations import errno import os from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import pytest import yaml from fastapi.testclient import TestClient from app.gateway.routers.agents import AGENT_NAME_PATTERN as GATEWAY_AGENT_NAME_PATTERN from deerflow.agents.memory.backends.deermem.deermem.core.paths import AGENT_NAME_PATTERN as DEERMEM_AGENT_NAME_PATTERN from deerflow.agents.memory.backends.deermem.deermem.core.paths import DEFAULT_AGENT_BUCKET, agent_facts_directory, validate_agent_name from deerflow.config.agents_api_config import AgentsApiConfig, get_agents_api_config, set_agents_api_config # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def test_reserved_memory_bucket_stays_outside_both_public_agent_patterns() -> None: assert GATEWAY_AGENT_NAME_PATTERN.fullmatch(DEFAULT_AGENT_BUCKET) is None assert DEERMEM_AGENT_NAME_PATTERN.fullmatch(DEFAULT_AGENT_BUCKET) is None validate_agent_name(DEFAULT_AGENT_BUCKET) # Internal storage sentinel remains usable. @pytest.mark.parametrize("name", ["reviewer\n", "reviewer \n"]) def test_agent_name_validation_rejects_trailing_newline(name: str) -> None: """``$`` in ``^[A-Za-z0-9-]+$`` also matches before a final newline. Only ``fullmatch`` anchors it, so DeerMem's inlined copy of the host's agent-name grammar accepted ``"reviewer\\n"`` and used it as a directory name — which the host's own strict validator then refuses forever. ``"reviewer \\n"`` was already rejected by ``.match`` (the space falls outside the class, so the match never reaches ``$``); it is parametrized here to pin the grammar, not because it regressed. """ with pytest.raises(ValueError, match="Invalid agent name"): validate_agent_name(name) with pytest.raises(ValueError, match="Invalid agent name"): agent_facts_directory(Path("memory.json"), name) def _make_paths(base_dir: Path): """Return a Paths instance pointing to base_dir.""" from deerflow.config.paths import Paths return Paths(base_dir=base_dir) def _write_agent(base_dir: Path, name: str, config: dict, soul: str = "You are helpful.") -> None: """Write an agent directory with config.yaml and SOUL.md.""" agent_dir = base_dir / "agents" / name agent_dir.mkdir(parents=True, exist_ok=True) config_copy = dict(config) if "name" not in config_copy: config_copy["name"] = name with open(agent_dir / "config.yaml", "w") as f: yaml.dump(config_copy, f) (agent_dir / "SOUL.md").write_text(soul, encoding="utf-8") # =========================================================================== # 1. Paths class – agent path methods # =========================================================================== class TestPaths: def test_agents_dir(self, tmp_path): paths = _make_paths(tmp_path) assert paths.agents_dir == tmp_path / "agents" def test_agent_dir(self, tmp_path): paths = _make_paths(tmp_path) assert paths.agent_dir("code-reviewer") == tmp_path / "agents" / "code-reviewer" def test_agent_memory_file(self, tmp_path): paths = _make_paths(tmp_path) assert paths.agent_memory_file("code-reviewer") == tmp_path / "agents" / "code-reviewer" / "memory.json" def test_user_md_file(self, tmp_path): paths = _make_paths(tmp_path) assert paths.user_md_file("alice") == tmp_path / "users" / "alice" / "USER.md" assert paths.user_md_file("bob") != paths.user_md_file("alice") def test_paths_are_different_from_global(self, tmp_path): paths = _make_paths(tmp_path) assert paths.memory_file != paths.agent_memory_file("my-agent") assert paths.memory_file == tmp_path / "memory.json" assert paths.agent_memory_file("my-agent") == tmp_path / "agents" / "my-agent" / "memory.json" # =========================================================================== # 2. AgentConfig – Pydantic parsing # =========================================================================== class TestAgentConfig: def test_minimal_config(self): from deerflow.config.agents_config import AgentConfig cfg = AgentConfig(name="my-agent") assert cfg.name == "my-agent" assert cfg.description == "" assert cfg.model is None assert cfg.tool_groups is None def test_full_config(self): from deerflow.config.agents_config import AgentConfig cfg = AgentConfig( name="code-reviewer", description="Specialized for code review", model="deepseek-v3", tool_groups=["file:read", "bash"], ) assert cfg.name == "code-reviewer" assert cfg.model == "deepseek-v3" assert cfg.tool_groups == ["file:read", "bash"] def test_config_from_dict(self): from deerflow.config.agents_config import AgentConfig data = {"name": "test-agent", "description": "A test", "model": "gpt-4"} cfg = AgentConfig(**data) assert cfg.name == "test-agent" assert cfg.model == "gpt-4" assert cfg.tool_groups is None # =========================================================================== # 3. load_agent_config # =========================================================================== class TestLoadAgentConfig: def test_load_valid_config(self, tmp_path): config_dict = {"name": "code-reviewer", "description": "Code review agent", "model": "deepseek-v3"} _write_agent(tmp_path, "code-reviewer", config_dict) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config cfg = load_agent_config("code-reviewer") assert cfg.name == "code-reviewer" assert cfg.description == "Code review agent" assert cfg.model == "deepseek-v3" def test_load_missing_agent_raises(self, tmp_path): with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config with pytest.raises(FileNotFoundError): load_agent_config("nonexistent-agent") def test_load_missing_config_yaml_raises(self, tmp_path): # Create directory without config.yaml (tmp_path / "agents" / "broken-agent").mkdir(parents=True) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config with pytest.raises(FileNotFoundError): load_agent_config("broken-agent") def test_load_config_infers_name_from_dir(self, tmp_path): """Config without 'name' field should use directory name.""" agent_dir = tmp_path / "agents" / "inferred-name" agent_dir.mkdir(parents=True) (agent_dir / "config.yaml").write_text("description: My agent\n") (agent_dir / "SOUL.md").write_text("Hello") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config cfg = load_agent_config("inferred-name") assert cfg.name == "inferred-name" def test_load_config_with_tool_groups(self, tmp_path): config_dict = {"name": "restricted", "tool_groups": ["file:read", "file:write"]} _write_agent(tmp_path, "restricted", config_dict) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config cfg = load_agent_config("restricted") assert cfg.tool_groups == ["file:read", "file:write"] def test_load_config_with_skills_empty_list(self, tmp_path): config_dict = {"name": "no-skills-agent", "skills": []} _write_agent(tmp_path, "no-skills-agent", config_dict) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config cfg = load_agent_config("no-skills-agent") assert cfg.skills == [] def test_load_config_with_skills_omitted(self, tmp_path): config_dict = {"name": "default-skills-agent"} _write_agent(tmp_path, "default-skills-agent", config_dict) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config cfg = load_agent_config("default-skills-agent") assert cfg.skills is None def test_legacy_prompt_file_field_ignored(self, tmp_path): """Unknown fields like the old prompt_file should be silently ignored.""" agent_dir = tmp_path / "agents" / "legacy-agent" agent_dir.mkdir(parents=True) (agent_dir / "config.yaml").write_text("name: legacy-agent\nprompt_file: system.md\n") (agent_dir / "SOUL.md").write_text("Soul content") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_config cfg = load_agent_config("legacy-agent") assert cfg.name == "legacy-agent" # =========================================================================== # 3b. resolve_agent_dir — memory-only directory fallback (#3390) # =========================================================================== class TestResolveAgentDirMemoryOnlyFallback: """Regression tests for #3390. When memory is enabled, the first conversation creates a user-isolated agent directory containing only ``memory.json`` (no ``config.yaml``). On the next turn ``resolve_agent_dir`` must fall through to the legacy shared layout instead of returning the incomplete user directory. """ def test_user_dir_with_only_memory_falls_back_to_legacy(self, tmp_path): """User dir has memory.json but no config.yaml → use legacy dir.""" from deerflow.config.agents_config import resolve_agent_dir # Legacy agent with full config legacy_dir = tmp_path / "agents" / "my-agent" legacy_dir.mkdir(parents=True) (legacy_dir / "config.yaml").write_text("name: my-agent\n", encoding="utf-8") (legacy_dir / "SOUL.md").write_text("legacy soul", encoding="utf-8") # User dir created by memory write — no config.yaml user_dir = tmp_path / "users" / "u1" / "agents" / "my-agent" user_dir.mkdir(parents=True) (user_dir / "memory.json").write_text("{}", encoding="utf-8") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)), patch("deerflow.config.agents_config.get_effective_user_id", return_value="u1"): result = resolve_agent_dir("my-agent", user_id="u1") assert result == legacy_dir def test_user_dir_with_config_takes_priority(self, tmp_path): """User dir with config.yaml should still win over legacy.""" from deerflow.config.agents_config import resolve_agent_dir # Legacy legacy_dir = tmp_path / "agents" / "my-agent" legacy_dir.mkdir(parents=True) (legacy_dir / "config.yaml").write_text("name: my-agent\n", encoding="utf-8") # User dir with full config (migrated) user_dir = tmp_path / "users" / "u1" / "agents" / "my-agent" user_dir.mkdir(parents=True) (user_dir / "config.yaml").write_text("name: my-agent\nmodel: gpt-4\n", encoding="utf-8") (user_dir / "memory.json").write_text("{}", encoding="utf-8") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)), patch("deerflow.config.agents_config.get_effective_user_id", return_value="u1"): result = resolve_agent_dir("my-agent", user_id="u1") assert result == user_dir def test_load_config_falls_back_when_user_dir_is_memory_only(self, tmp_path): """End-to-end: load_agent_config works when user dir only has memory.json.""" config_dict = {"name": "my-agent", "description": "Legacy agent", "model": "deepseek-v3"} _write_agent(tmp_path, "my-agent", config_dict) # Simulate memory write creating user dir without config user_dir = tmp_path / "users" / "u1" / "agents" / "my-agent" user_dir.mkdir(parents=True) (user_dir / "memory.json").write_text("{}", encoding="utf-8") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)), patch("deerflow.config.agents_config.get_effective_user_id", return_value="u1"): from deerflow.config.agents_config import load_agent_config cfg = load_agent_config("my-agent", user_id="u1") assert cfg.name == "my-agent" assert cfg.model == "deepseek-v3" # =========================================================================== # 4. load_agent_soul # =========================================================================== class TestLoadAgentSoul: def test_reads_soul_file(self, tmp_path): expected_soul = "You are a specialized code review expert." _write_agent(tmp_path, "code-reviewer", {"name": "code-reviewer"}, soul=expected_soul) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import AgentConfig, load_agent_soul cfg = AgentConfig(name="code-reviewer") soul = load_agent_soul(cfg.name) assert soul == expected_soul def test_strips_a_leading_bom_from_named_agent_soul(self, tmp_path): """A SOUL.md saved by a Windows editor keeps a UTF-8 BOM. ``str.strip()`` does not remove U+FEFF, so without normalization the mark becomes the first character of the text injected into the lead agent's ```` block. SOUL.md is edited outside DeerFlow (Notepad, PowerShell 5.1), which is exactly where a BOM comes from. """ expected_soul = "You are a specialized code review expert." _write_agent(tmp_path, "code-reviewer", {"name": "code-reviewer"}, soul="\ufeff" + expected_soul) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import AgentConfig, load_agent_soul cfg = AgentConfig(name="code-reviewer") soul = load_agent_soul(cfg.name) assert soul == expected_soul def test_strips_a_leading_bom_from_the_default_agent_soul(self, tmp_path): """The default agent reads ``{base_dir}/SOUL.md`` directly — a second read site.""" expected_soul = "You are the default agent." (tmp_path / "SOUL.md").write_text("\ufeff" + expected_soul, encoding="utf-8") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import load_agent_soul soul = load_agent_soul(None) assert soul == expected_soul def test_missing_soul_file_returns_none(self, tmp_path): agent_dir = tmp_path / "agents" / "no-soul" agent_dir.mkdir(parents=True) (agent_dir / "config.yaml").write_text("name: no-soul\n") # No SOUL.md created with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import AgentConfig, load_agent_soul cfg = AgentConfig(name="no-soul") soul = load_agent_soul(cfg.name) assert soul is None def test_empty_soul_file_returns_none(self, tmp_path): agent_dir = tmp_path / "agents" / "empty-soul" agent_dir.mkdir(parents=True) (agent_dir / "config.yaml").write_text("name: empty-soul\n") (agent_dir / "SOUL.md").write_text(" \n ") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import AgentConfig, load_agent_soul cfg = AgentConfig(name="empty-soul") soul = load_agent_soul(cfg.name) assert soul is None def test_loads_soul_without_config_yaml(self, tmp_path): """SOUL.md should load even when the agent dir has no config.yaml (#4135).""" agent_dir = tmp_path / "agents" / "soul-only" agent_dir.mkdir(parents=True) # Deliberately no config.yaml – the agent is configured externally (agent_dir / "SOUL.md").write_text("You are a brave agent.", encoding="utf-8") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)), patch("deerflow.config.agents_config.get_effective_user_id", return_value="default"): from deerflow.config.agents_config import load_agent_soul soul = load_agent_soul("soul-only") assert soul == "You are a brave agent." def test_loads_soul_from_user_dir_without_config_yaml(self, tmp_path): """Fallback should find SOUL.md when resolver returns a default dir without it (#4135). Setup: per-user agent 'foo' exists as a memory-only directory (no config.yaml, no SOUL.md). Legacy agent 'foo' has SOUL.md but no config.yaml. resolve_agent_dir returns the per-user path as default (neither dir has config.yaml). The fallback then finds SOUL.md in the legacy directory. """ # Per-user dir: memory-only (no config.yaml, no SOUL.md) user_dir = tmp_path / "users" / "test-user" / "agents" / "foo" user_dir.mkdir(parents=True) (user_dir / "memory.json").write_text("{}", encoding="utf-8") # Legacy dir: has SOUL.md but no config.yaml legacy_dir = tmp_path / "agents" / "foo" legacy_dir.mkdir(parents=True) (legacy_dir / "SOUL.md").write_text("You are a legacy agent.", encoding="utf-8") with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)), patch("deerflow.config.agents_config.get_effective_user_id", return_value="test-user"): from deerflow.config.agents_config import load_agent_soul soul = load_agent_soul("foo") assert soul == "You are a legacy agent." def test_soul_not_leaked_from_legacy_when_per_user_has_config(self, tmp_path): """Per-user agent with config.yaml but no SOUL.md should NOT fall back to legacy SOUL.md. This verifies the gated condition: fallback only fires when the resolved dir lacks config.yaml. A properly-resolved per-user agent that simply has no SOUL.md returns None, preserving the "per-user entries fully shadow legacy entries" invariant. """ # Legacy dir: has SOUL.md legacy_dir = tmp_path / "agents" / "foo" legacy_dir.mkdir(parents=True) (legacy_dir / "config.yaml").write_text("name: foo\n") (legacy_dir / "SOUL.md").write_text("You are a legacy agent.", encoding="utf-8") # Per-user dir: has config.yaml (resolver returns this) but no SOUL.md user_dir = tmp_path / "users" / "test-user" / "agents" / "foo" user_dir.mkdir(parents=True) (user_dir / "config.yaml").write_text("name: foo\n") # No SOUL.md in per-user dir with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)), patch("deerflow.config.agents_config.get_effective_user_id", return_value="test-user"): from deerflow.config.agents_config import load_agent_soul soul = load_agent_soul("foo") assert soul is None # =========================================================================== # 5. list_custom_agents # =========================================================================== class TestListCustomAgents: def test_empty_when_no_agents_dir(self, tmp_path): with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import list_custom_agents agents = list_custom_agents() assert agents == [] def test_discovers_multiple_agents(self, tmp_path): _write_agent(tmp_path, "agent-a", {"name": "agent-a"}) _write_agent(tmp_path, "agent-b", {"name": "agent-b", "description": "B"}) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import list_custom_agents agents = list_custom_agents() names = [a.name for a in agents] assert "agent-a" in names assert "agent-b" in names def test_skips_dirs_without_config_yaml(self, tmp_path): # Valid agent _write_agent(tmp_path, "valid-agent", {"name": "valid-agent"}) # Invalid dir (no config.yaml) (tmp_path / "agents" / "invalid-dir").mkdir(parents=True) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import list_custom_agents agents = list_custom_agents() assert len(agents) == 1 assert agents[0].name == "valid-agent" def test_skips_non_directory_entries(self, tmp_path): # Create the agents dir with a file (not a dir) agents_dir = tmp_path / "agents" agents_dir.mkdir(parents=True) (agents_dir / "not-a-dir.txt").write_text("hello") _write_agent(tmp_path, "real-agent", {"name": "real-agent"}) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import list_custom_agents agents = list_custom_agents() assert len(agents) == 1 assert agents[0].name == "real-agent" def test_returns_sorted_by_name(self, tmp_path): _write_agent(tmp_path, "z-agent", {"name": "z-agent"}) _write_agent(tmp_path, "a-agent", {"name": "a-agent"}) _write_agent(tmp_path, "m-agent", {"name": "m-agent"}) with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): from deerflow.config.agents_config import list_custom_agents agents = list_custom_agents() names = [a.name for a in agents] assert names == sorted(names) # =========================================================================== # 7. Memory isolation: _get_memory_file_path # =========================================================================== class TestMemoryFilePath: def test_global_memory_path(self, tmp_path, monkeypatch): """None agent_name should return global memory file.""" from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage monkeypatch.setenv("DEERMEM_DATA_DIR", str(tmp_path)) storage = FileMemoryStorage(DeerMemConfig()) path = storage._get_memory_file_path(None) assert path == tmp_path / "memory.json" def test_agent_memory_path(self, tmp_path, monkeypatch): """All agents share the user-global summary JSON path.""" from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage monkeypatch.setenv("DEERMEM_DATA_DIR", str(tmp_path)) storage = FileMemoryStorage(DeerMemConfig()) path = storage._get_memory_file_path("code-reviewer") assert path == tmp_path / "memory.json" def test_agents_share_summary_path(self, tmp_path, monkeypatch): from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage monkeypatch.setenv("DEERMEM_DATA_DIR", str(tmp_path)) storage = FileMemoryStorage(DeerMemConfig()) path_global = storage._get_memory_file_path(None) path_a = storage._get_memory_file_path("agent-a") path_b = storage._get_memory_file_path("agent-b") assert path_global == path_a == path_b # =========================================================================== # 8. Gateway API – Agents endpoints # =========================================================================== # Model names the agents API tests may send in a create/update payload. The # router validates `model` against the app config, so the fixture pins a stub # config exposing exactly these instead of leaving the assertion at the mercy # of whatever `config.yaml` happens to sit in the repo root: CI has none (the # validation is skipped and the request passes), a real dev checkout does (an # unlisted model name yields 422 and the test fails). _KNOWN_TEST_MODELS = frozenset({"deepseek-v3"}) def _stub_app_config(): """App config that knows only ``_KNOWN_TEST_MODELS``.""" return SimpleNamespace(get_model_config=lambda name: SimpleNamespace(name=name) if name in _KNOWN_TEST_MODELS else None) def _make_test_app(tmp_path: Path): """Create a FastAPI app with the agents router, patching paths to tmp_path. Uses the stub-auth helper so the ``@require_permission`` decorators on the agents routes see an authenticated user with all permissions (mirroring what ``AuthMiddleware`` does in the real gateway). """ from _router_auth_helpers import make_authed_test_app from app.gateway.routers.agents import router app = make_authed_test_app() app.include_router(router) return app @pytest.fixture() def agent_client(tmp_path): """TestClient with agents router, using tmp_path as base_dir.""" import app.gateway.routers.agents as agents_router paths_instance = _make_paths(tmp_path) previous_config = AgentsApiConfig(**get_agents_api_config().model_dump()) with ( patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance), patch.object(agents_router, "get_app_config", _stub_app_config), ): set_agents_api_config(AgentsApiConfig(enabled=True)) try: app = _make_test_app(tmp_path) with TestClient(app) as client: client._tmp_path = tmp_path # type: ignore[attr-defined] yield client finally: set_agents_api_config(previous_config) @pytest.fixture() def disabled_agent_client(tmp_path): """TestClient with agents router while the management API is disabled.""" import app.gateway.routers.agents as agents_router paths_instance = _make_paths(tmp_path) previous_config = AgentsApiConfig(**get_agents_api_config().model_dump()) with patch("deerflow.config.agents_config.get_paths", return_value=paths_instance), patch.object(agents_router, "get_paths", return_value=paths_instance): set_agents_api_config(AgentsApiConfig(enabled=False)) try: app = _make_test_app(tmp_path) with TestClient(app) as client: yield client finally: set_agents_api_config(previous_config) class TestAgentsAPI: def test_agent_package_round_trip_preserves_portable_behavior(self, agent_client): payload = { "name": "research-lead", "display_name": "Research Lead", "description": "Coordinates a research team", "model": "deepseek-v3", "tool_groups": ["web", "file:read"], "mcp_plugins": ["papers"], "skills": ["literature-review"], "allowed_subagents": ["researcher", "reporter"], "model_settings": {"temperature": 0.2, "max_tokens": 12000}, "thinking_enabled": True, "reasoning_effort": "high", "soul": "Delegate independent searches, then synthesize evidence.", } assert agent_client.post("/api/agents", json=payload).status_code == 201 exported = agent_client.get("/api/agents/research-lead/export") assert exported.status_code == 200 assert exported.headers["content-disposition"] == 'attachment; filename="research-lead.deerflow-agent.json"' package = exported.json() assert package == { "format": "deerflow.custom-agent", "version": 1, "agent": {**payload, "memory_enabled": True}, } imported = agent_client.post( "/api/agents/import", params={"name": "research-lead-copy"}, json=package, ) assert imported.status_code == 201 assert imported.json() == {**payload, "name": "research-lead-copy", "knowledge_scope": None} def test_agent_package_excludes_runtime_state_and_operator_github_binding(self, agent_client, tmp_path): assert agent_client.post("/api/agents", json={"name": "portable", "soul": "Portable soul"}).status_code == 201 agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "portable" config_file = agent_dir / "config.yaml" config = yaml.safe_load(config_file.read_text(encoding="utf-8")) config["memory_enabled"] = False config["github"] = { "installation_id": 12345, "bot_login": "private-bot", "bindings": [{"repo": "private/repository"}], } config_file.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") (agent_dir / "memory.json").write_text('{"secret": "remembered"}', encoding="utf-8") package = agent_client.get("/api/agents/portable/export").json() assert package["agent"]["memory_enabled"] is False assert "github" not in package["agent"] assert "memory" not in package assert "memory" not in package["agent"] assert "secret" not in str(package) @pytest.mark.parametrize( "package", [ {"agent": {"name": "bad", "soul": "x"}}, {"format": "deerflow.custom-agent", "agent": {"name": "bad", "soul": "x"}}, {"format": "deerflow.custom-agent", "version": 2, "agent": {"name": "bad", "soul": "x"}}, {"format": "unknown", "version": 1, "agent": {"name": "bad", "soul": "x"}}, { "format": "deerflow.custom-agent", "version": 1, "agent": {"name": "bad", "soul": "x", "github": {"installation_id": 1}}, }, {"format": "deerflow.custom-agent", "version": 1, "agent": {"name": "bad name", "soul": "x"}}, ], ) def test_agent_package_import_rejects_unsupported_or_unsafe_documents(self, agent_client, package): response = agent_client.post("/api/agents/import", json=package) assert response.status_code == 422 assert agent_client.get("/api/agents").json()["agents"] == [] def test_agent_package_import_conflicts_explicitly_and_never_overwrites(self, agent_client): assert agent_client.post("/api/agents", json={"name": "reviewer", "soul": "original"}).status_code == 201 package = { "format": "deerflow.custom-agent", "version": 1, "agent": {"name": "reviewer", "description": "incoming", "soul": "replacement"}, } response = agent_client.post("/api/agents/import", json=package) assert response.status_code == 409 assert agent_client.get("/api/agents/reviewer").json()["soul"] == "original" def test_agent_package_import_is_scoped_to_current_user(self, agent_client, tmp_path): import app.gateway.routers.agents as agents_router package = { "format": "deerflow.custom-agent", "version": 1, "agent": {"name": "shared-agent", "description": "shared definition", "soul": "shared soul"}, } with patch.object(agents_router, "get_effective_user_id", return_value="alice"): assert agent_client.post("/api/agents/import", json=package).status_code == 201 with patch.object(agents_router, "get_effective_user_id", return_value="bob"): assert agent_client.post("/api/agents/import", json=package).status_code == 201 alice_dir = tmp_path / "users" / "alice" / "agents" / "shared-agent" bob_dir = tmp_path / "users" / "bob" / "agents" / "shared-agent" assert (alice_dir / "SOUL.md").read_text(encoding="utf-8") == "shared soul" assert (bob_dir / "SOUL.md").read_text(encoding="utf-8") == "shared soul" assert alice_dir != bob_dir def test_agent_package_round_trip_uses_configured_store_abstraction(self, agent_client, tmp_path): from sqlalchemy import create_engine import app.gateway.routers.agents as agents_router from deerflow.persistence.agents.model import AgentRow from deerflow.persistence.agents.sql import SqlAgentStore from deerflow.persistence.base import Base url = f"sqlite:///{tmp_path}/agents.db" engine = create_engine(url) Base.metadata.create_all(engine, tables=[AgentRow.__table__]) engine.dispose() store = SqlAgentStore(url) package = { "format": "deerflow.custom-agent", "version": 1, "agent": {"name": "database-agent", "description": "shared backend", "soul": "database soul"}, } with ( patch.object(agents_router, "get_agent_store", return_value=store), patch("deerflow.persistence.agents.get_agent_store", return_value=store), ): imported = agent_client.post("/api/agents/import", json=package) exported = agent_client.get("/api/agents/database-agent/export") assert imported.status_code == 201 assert exported.status_code == 200 assert exported.json()["agent"] == { "name": "database-agent", "description": "shared backend", "memory_enabled": True, "soul": "database soul", } @pytest.mark.parametrize("display_name", ["x" * 150, 123, "\u200b" * 3]) def test_invalid_stored_display_name_falls_back_in_api(self, agent_client, display_name): from deerflow.persistence.agents.file import FileAgentStore FileAgentStore().create("reviewer", {"display_name": display_name, "description": "healthy"}, "Soul") response = agent_client.get("/api/agents/reviewer") assert response.status_code == 200 assert response.json()["display_name"] is None assert response.json()["description"] == "healthy" assert agent_client.get("/api/agents").json()["agents"][0]["name"] == "reviewer" response = agent_client.put("/api/agents/reviewer", json={"display_name": "已修复"}) assert response.status_code == 200 assert response.json()["display_name"] == "已修复" @pytest.mark.parametrize("display_name", ["a\u202eb", "line1\nline2", "z\x00ero", "\u200b" * 3, "a\u200fb", "a\u2028b", "\u200c\u200d"]) def test_invalid_display_name_cannot_be_persisted(self, agent_client, display_name): assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": display_name}).status_code == 422 assert agent_client.get("/api/agents").json()["agents"] == [] assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": "🦌" * 100}).status_code == 201 assert agent_client.put("/api/agents/reviewer", json={"display_name": display_name}).status_code == 422 assert agent_client.get("/api/agents/reviewer").json()["display_name"] == "🦌" * 100 @pytest.mark.parametrize("name", ["reviewer\n", "reviewer\n\n"]) def test_trailing_newline_in_agent_name_is_rejected(self, agent_client, name): """The router's ``AGENT_NAME_PATTERN.match`` accepted ``"reviewer\\n"`` and the store 500'd. ``$`` matches before a single trailing newline, so that one param reached the file store, which validates the same grammar with ``fullmatch``. ``"reviewer\\n\\n"`` was already rejected by ``.match`` on main; it is parametrized to pin the grammar, not because it regressed. """ assert agent_client.post("/api/agents", json={"name": name}).status_code == 422 assert agent_client.get("/api/agents").json()["agents"] == [] def test_display_name_round_trip_keeps_stable_identity(self, agent_client): response = agent_client.post("/api/agents", json={"name": "code-reviewer", "display_name": " 代码审查助手 "}) assert response.status_code == 201 assert response.json()["display_name"] == "代码审查助手" assert response.json()["name"] == "code-reviewer" assert agent_client.get("/api/agents").json()["agents"][0]["display_name"] == "代码审查助手" response = agent_client.put("/api/agents/code-reviewer", json={"description": "Updated"}) assert response.json()["display_name"] == "代码审查助手" response = agent_client.put("/api/agents/code-reviewer", json={"display_name": "审查员 🦌"}) assert response.json()["display_name"] == "审查员 🦌" assert agent_client.get("/api/agents/code-reviewer").json()["display_name"] == "审查员 🦌" response = agent_client.put("/api/agents/code-reviewer", json={"display_name": None}) assert response.json()["display_name"] is None assert response.json()["name"] == "code-reviewer" def test_display_name_validation_does_not_relax_agent_identifier(self, agent_client): assert agent_client.post("/api/agents", json={"name": "中文"}).status_code == 422 assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": "名" * 101}).status_code == 422 assert agent_client.post("/api/agents", json={"name": "reviewer", "display_name": "名" * 100}).status_code == 201 assert agent_client.put("/api/agents/reviewer", json={"display_name": "名" * 101}).status_code == 422 assert agent_client.get("/api/agents/reviewer").json()["display_name"] == "名" * 100 def test_list_agents_empty(self, agent_client): response = agent_client.get("/api/agents") assert response.status_code == 200 data = response.json() assert data["agents"] == [] def test_create_agent(self, agent_client): payload = { "name": "code-reviewer", "description": "Reviews code", "soul": "You are a code reviewer.", } response = agent_client.post("/api/agents", json=payload) assert response.status_code == 201 data = response.json() assert data["name"] == "code-reviewer" assert data["description"] == "Reviews code" assert data["soul"] == "You are a code reviewer." def test_create_agent_invalid_name(self, agent_client): payload = {"name": "Code Reviewer!", "soul": "test"} response = agent_client.post("/api/agents", json=payload) assert response.status_code == 422 def test_create_duplicate_agent_409(self, agent_client): payload = {"name": "my-agent", "soul": "test"} agent_client.post("/api/agents", json=payload) # Second create should fail response = agent_client.post("/api/agents", json=payload) assert response.status_code == 409 def test_list_agents_after_create(self, agent_client): agent_client.post("/api/agents", json={"name": "agent-one", "soul": "p1"}) agent_client.post("/api/agents", json={"name": "agent-two", "soul": "p2"}) response = agent_client.get("/api/agents") assert response.status_code == 200 names = [a["name"] for a in response.json()["agents"]] assert "agent-one" in names assert "agent-two" in names def test_list_agents_includes_soul(self, agent_client): agent_client.post("/api/agents", json={"name": "soul-agent", "soul": "My soul content"}) response = agent_client.get("/api/agents") assert response.status_code == 200 agents = response.json()["agents"] soul_agent = next(a for a in agents if a["name"] == "soul-agent") assert soul_agent["soul"] == "My soul content" def test_get_agent(self, agent_client): agent_client.post("/api/agents", json={"name": "test-agent", "soul": "Hello world"}) response = agent_client.get("/api/agents/test-agent") assert response.status_code == 200 data = response.json() assert data["name"] == "test-agent" assert data["soul"] == "Hello world" def test_get_missing_agent_404(self, agent_client): response = agent_client.get("/api/agents/nonexistent") assert response.status_code == 404 def test_update_agent_soul(self, agent_client): agent_client.post("/api/agents", json={"name": "update-me", "soul": "original"}) response = agent_client.put("/api/agents/update-me", json={"soul": "updated"}) assert response.status_code == 200 assert response.json()["soul"] == "updated" def test_update_agent_description(self, agent_client): agent_client.post("/api/agents", json={"name": "desc-agent", "description": "old desc", "soul": "p"}) response = agent_client.put("/api/agents/desc-agent", json={"description": "new desc"}) assert response.status_code == 200 assert response.json()["description"] == "new desc" def test_update_agent_preserves_hand_authored_non_managed_fields(self, agent_client): """Hand-authored ``github:`` and ``memory_enabled`` fields must survive PATCH. The HTTP route does not expose ``github`` as an editable field (and rightly so — the GitHub App credentials and binding triggers are operator-authored, not end-user-editable). But it MUST carry the block forward when rewriting ``config.yaml`` for a description / model / tool_groups / skills change, otherwise an operator who edits the agent's description from the Web UI silently strips the binding and the next webhook delivery silently no-ops. Mirrors the same property the harness ``update_agent`` tool enforces via ``preserve_non_managed_fields``; both surfaces share the helper. """ # Create an agent through the API, then hand-author a github: block # into its config.yaml — exactly the workflow an operator would # follow when wiring a new repo binding. agent_client.post("/api/agents", json={"name": "github-agent", "description": "old desc", "soul": "p"}) tmp_path: Path = agent_client._tmp_path # type: ignore[attr-defined] agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "github-agent" config_file = agent_dir / "config.yaml" config_data = yaml.safe_load(config_file.read_text()) config_data["github"] = { "installation_id": 99999, "bot_login": "github-agent-bot", "bindings": [ { "repo": "acme/widget", "triggers": {"pull_request": {"actions": ["opened"]}}, } ], } config_data["memory_enabled"] = False config_file.write_text(yaml.safe_dump(config_data, sort_keys=False), encoding="utf-8") # PATCH only the description. response = agent_client.put("/api/agents/github-agent", json={"description": "new desc"}) assert response.status_code == 200 # github: block must survive verbatim. reloaded = yaml.safe_load(config_file.read_text()) assert reloaded["description"] == "new desc" assert reloaded["memory_enabled"] is False assert reloaded["github"] == { "installation_id": 99999, "bot_login": "github-agent-bot", "bindings": [ { "repo": "acme/widget", "triggers": {"pull_request": {"actions": ["opened"]}}, } ], } def test_update_memory_only_user_dir_with_legacy_agent_returns_409(self, agent_client, tmp_path): """Regression for #3390's PUT /api/agents/{name} guard. A per-user agent directory can exist containing only memory.json (written the first time this user chats with a legacy shared agent). The stale guard checked bare directory existence and missed this case, letting the route silently fork a brand-new config.yaml/SOUL.md into the memory-only directory instead of blocking with the migration-script guidance. """ legacy_dir = tmp_path / "agents" / "legacy-agent" legacy_dir.mkdir(parents=True) (legacy_dir / "config.yaml").write_text("name: legacy-agent\ndescription: legacy\n", encoding="utf-8") (legacy_dir / "SOUL.md").write_text("legacy soul", encoding="utf-8") user_agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "legacy-agent" user_agent_dir.mkdir(parents=True) (user_agent_dir / "memory.json").write_text("{}", encoding="utf-8") response = agent_client.put("/api/agents/legacy-agent", json={"soul": "should not write"}) assert response.status_code == 409 assert not (user_agent_dir / "config.yaml").exists() assert not (user_agent_dir / "SOUL.md").exists() assert (user_agent_dir / "memory.json").exists(), "the user's existing memory must be left untouched" def test_update_missing_agent_404(self, agent_client): response = agent_client.put("/api/agents/ghost-agent", json={"soul": "new"}) assert response.status_code == 404 def test_delete_agent(self, agent_client): agent_client.post("/api/agents", json={"name": "del-me", "soul": "bye"}) response = agent_client.delete("/api/agents/del-me") assert response.status_code == 204 # Verify it's gone response = agent_client.get("/api/agents/del-me") assert response.status_code == 404 def test_delete_missing_agent_404(self, agent_client): response = agent_client.delete("/api/agents/does-not-exist") assert response.status_code == 404 def test_delete_rejects_memory_only_directory_without_removing_facts(self, agent_client, tmp_path): agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "lead-agent" facts_dir = agent_dir / "facts" facts_dir.mkdir(parents=True) fact_path = facts_dir / "fact_keep.md" fact_path.write_text("memory data", encoding="utf-8") response = agent_client.delete("/api/agents/lead-agent") assert response.status_code == 409 assert fact_path.read_text(encoding="utf-8") == "memory data" def test_reserved_default_bucket_cannot_be_created_as_custom_agent(self, agent_client): response = agent_client.post("/api/agents", json={"name": "__default__", "soul": "must fail"}) assert response.status_code == 422 def test_create_agent_with_model_and_tool_groups(self, agent_client): payload = { "name": "specialized", "description": "Specialized agent", "model": "deepseek-v3", "tool_groups": ["file:read", "bash"], "soul": "You are specialized.", } response = agent_client.post("/api/agents", json=payload) assert response.status_code == 201 data = response.json() assert data["model"] == "deepseek-v3" assert data["tool_groups"] == ["file:read", "bash"] def test_create_persists_files_on_disk(self, agent_client, tmp_path): agent_client.post("/api/agents", json={"name": "disk-check", "soul": "disk soul"}) # tests/conftest.py installs an autouse fixture that sets the # contextvar to "test-user-autouse", so the agent is persisted under # users/test-user-autouse/agents/ rather than the legacy shared dir. agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "disk-check" assert agent_dir.exists() assert (agent_dir / "config.yaml").exists() assert (agent_dir / "SOUL.md").exists() assert (agent_dir / "SOUL.md").read_text() == "disk soul" def test_delete_removes_files_from_disk(self, agent_client, tmp_path): agent_client.post("/api/agents", json={"name": "remove-me", "soul": "bye"}) agent_dir = tmp_path / "users" / "test-user-autouse" / "agents" / "remove-me" assert agent_dir.exists() agent_client.delete("/api/agents/remove-me") assert not agent_dir.exists() def test_create_rejects_legacy_name_collision(self, agent_client, tmp_path): """An unmigrated legacy agent must still block name collision so that running the migration script later won't shadow the legacy entry.""" legacy_dir = tmp_path / "agents" / "legacy-agent" legacy_dir.mkdir(parents=True) (legacy_dir / "config.yaml").write_text("name: legacy-agent\n", encoding="utf-8") (legacy_dir / "SOUL.md").write_text("legacy soul", encoding="utf-8") response = agent_client.post("/api/agents", json={"name": "legacy-agent", "soul": "x"}) assert response.status_code == 409 # =========================================================================== # 9. Gateway API – User Profile endpoints # =========================================================================== class TestUserProfileAPI: def test_get_user_profile_empty(self, agent_client): response = agent_client.get("/api/user-profile") assert response.status_code == 200 assert response.json()["content"] is None def test_put_user_profile(self, agent_client, tmp_path): content = "# User Profile\n\nI am a developer." response = agent_client.put("/api/user-profile", json={"content": content}) assert response.status_code == 200 assert response.json()["content"] == content # File should be written to the caller's per-user bucket. The autouse # _auto_user_context fixture in conftest.py sets user # "test-user-autouse", so that is the effective id here. user_md = tmp_path / "users" / "test-user-autouse" / "USER.md" assert user_md.exists() assert user_md.read_text(encoding="utf-8") == content def test_user_profile_is_isolated_per_user(self, agent_client, tmp_path): """A legacy global USER.md must never leak into a user's profile read. Pre-fix behavior: GET/PUT /api/user-profile read and wrote the shared ``{base_dir}/USER.md`` singleton, so any authenticated user could overwrite the prompt context injected for every other user. """ legacy_global = tmp_path / "USER.md" legacy_global.write_text("# injected by another user", encoding="utf-8") got = agent_client.get("/api/user-profile") assert got.status_code == 200 # Per-user file does not exist yet and the legacy global file is not # consulted as a fallback. assert got.json()["content"] is None def test_get_user_profile_after_put(self, agent_client): content = "# Profile\n\nI work on data science." agent_client.put("/api/user-profile", json={"content": content}) response = agent_client.get("/api/user-profile") assert response.status_code == 200 assert response.json()["content"] == content def test_put_empty_user_profile_returns_none(self, agent_client): response = agent_client.put("/api/user-profile", json={"content": ""}) assert response.status_code == 200 assert response.json()["content"] is None class TestAgentsAPIErrorDetail: """Unexpected-failure responses must not echo the exception text. Agent config parse errors and filesystem ``OSError``s name absolute host paths under ``users//``; they belong in the server log only. """ def _user_dir(self, tmp_path: Path) -> Path: return tmp_path / "users" / "test-user-autouse" def _break_agent_config(self, agent_client, tmp_path: Path) -> Path: assert agent_client.post("/api/agents", json={"name": "broken", "soul": "x"}).status_code == 201 config_file = self._user_dir(tmp_path) / "agents" / "broken" / "config.yaml" config_file.write_text("name: broken\n bad: [\n", encoding="utf-8") return config_file def test_get_agent_with_unparsable_config_returns_fixed_500(self, agent_client, tmp_path, caplog): config_file = self._break_agent_config(agent_client, tmp_path) response = agent_client.get("/api/agents/broken") assert response.status_code == 500 assert response.json() == {"detail": "Failed to get agent"} assert str(config_file) in caplog.text def test_update_agent_with_unparsable_config_returns_fixed_500(self, agent_client, tmp_path, caplog): config_file = self._break_agent_config(agent_client, tmp_path) response = agent_client.put("/api/agents/broken", json={"description": "new"}) assert response.status_code == 500 assert response.json() == {"detail": "Failed to update agent"} assert str(config_file) in caplog.text def test_list_agents_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog): agents_root = self._user_dir(tmp_path) / "agents" agents_root.parent.mkdir(parents=True) agents_root.write_text("not a directory", encoding="utf-8") response = agent_client.get("/api/agents") assert response.status_code == 500 assert response.json() == {"detail": "Failed to list agents"} assert str(agents_root) in caplog.text def test_create_agent_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog): agents_root = self._user_dir(tmp_path) / "agents" agents_root.parent.mkdir(parents=True) agents_root.write_text("not a directory", encoding="utf-8") response = agent_client.post("/api/agents", json={"name": "fresh", "soul": "x"}) assert response.status_code == 500 assert response.json() == {"detail": "Failed to create agent"} assert str(agents_root) in caplog.text def test_delete_agent_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog): from deerflow.persistence.agents.file import FileAgentStore assert agent_client.post("/api/agents", json={"name": "locked", "soul": "x"}).status_code == 201 agent_dir = self._user_dir(tmp_path) / "agents" / "locked" denied = PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(agent_dir)) with patch.object(FileAgentStore, "delete", side_effect=denied): response = agent_client.delete("/api/agents/locked") assert response.status_code == 500 assert response.json() == {"detail": "Failed to delete agent"} assert str(agent_dir) in caplog.text @pytest.mark.parametrize( ("method", "kwargs", "detail"), [ ("get", {}, "Failed to read user profile"), ("put", {"json": {"content": "hi"}}, "Failed to update user profile"), ], ) def test_user_profile_os_error_returns_fixed_500(self, agent_client, tmp_path, caplog, method, kwargs, detail): user_md = self._user_dir(tmp_path) / "USER.md" user_md.mkdir(parents=True) response = getattr(agent_client, method)("/api/user-profile", **kwargs) assert response.status_code == 500 assert response.json() == {"detail": detail} assert str(user_md) in caplog.text class TestAgentsApiDisabled: def test_agents_list_returns_403(self, disabled_agent_client): response = disabled_agent_client.get("/api/agents") assert response.status_code == 403 assert "agents_api.enabled=true" in response.json()["detail"] def test_agent_get_returns_403(self, disabled_agent_client): response = disabled_agent_client.get("/api/agents/example-agent") assert response.status_code == 403 def test_agent_name_check_returns_403(self, disabled_agent_client): response = disabled_agent_client.get("/api/agents/check", params={"name": "example-agent"}) assert response.status_code == 403 def test_agent_create_returns_403(self, disabled_agent_client): response = disabled_agent_client.post("/api/agents", json={"name": "example-agent", "soul": "blocked"}) assert response.status_code == 403 def test_agent_portability_routes_return_403(self, disabled_agent_client): package = { "format": "deerflow.custom-agent", "version": 1, "agent": {"name": "example-agent", "soul": "blocked"}, } assert disabled_agent_client.post("/api/agents/import", json=package).status_code == 403 assert disabled_agent_client.get("/api/agents/example-agent/export").status_code == 403 def test_agent_update_returns_403(self, disabled_agent_client): response = disabled_agent_client.put("/api/agents/example-agent", json={"description": "blocked"}) assert response.status_code == 403 def test_agent_delete_returns_403(self, disabled_agent_client): response = disabled_agent_client.delete("/api/agents/example-agent") assert response.status_code == 403 def test_user_profile_routes_return_403(self, disabled_agent_client): get_response = disabled_agent_client.get("/api/user-profile") put_response = disabled_agent_client.put("/api/user-profile", json={"content": "blocked"}) assert get_response.status_code == 403 assert put_response.status_code == 403