mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-04 20:08:40 +00:00
* feat(channels): add GitHub event-driven agents (#3754) Add a webhook-driven GitHub channel with fail-closed webhook routing, deterministic per-agent PR/issue threads, mention-gated trigger fan-out, GitHub App token injection for sandboxed gh/git commands, and backend/AGENTS.md documentation. * fix(llm-middleware): classify bare IndexError as transient Upstream chat providers occasionally return 200 OK with an empty generations list (observed against Volces "coding" on ark.cn-beijing.volces.com). When that happens, langchain_core.language_models.chat_models.ainvoke raises ``IndexError: list index out of range`` at ``llm_result.generations[0][0].message`` and kills the run. Treat a bare IndexError reaching the middleware as a transient upstream-payload glitch and route it through the existing retry/backoff path instead of failing the whole agent run. The retry budget and backoff schedule are unchanged. Adds three regression tests covering the classifier and both the recover-on-retry and exhausted-retries paths. * fix(runtime): ignore stale LLM fallback markers from prior runs When a run on a thread ends with the LLM-error-handling middleware emitting a `deerflow_error_fallback`-marked AIMessage (e.g. after the IndexError empty-generations classification fix lands), that message is persisted to the thread's checkpoint as part of the messages channel. LangGraph replays the full message history in `stream_mode="values"` chunks, so every subsequent run on the same thread re-streams the stale fallback marker — and the worker's chunk scanner faithfully picks it up, flipping `RunStatus.success` to `RunStatus.error` for runs that themselves had no LLM failure at all. Snapshot the set of pre-existing message ids from the pre-run checkpoint and thread it through `_extract_llm_error_fallback_message` / `_try_extract_from_message` as a filter. Markers on history messages are ignored; markers on fresh messages produced during this run still trip the error path. Falls back to an empty set when the checkpointer is absent or the snapshot can't be captured, preserving the prior behavior on first-run / no-state paths. Adds unit tests for the new filter (helper-level and `_collect_pre_existing_message_ids`) plus an integration test exercising the full `run_agent` path with a stale history checkpointer. * fix(channels): make github channel fire-and-forget to avoid httpx.ReadTimeout on long runs GitHub agent runs (clone -> edit -> test -> push -> PR) routinely exceed the langgraph_sdk default 300s read deadline. The manager's runs.wait call kept an HTTP stream open for the entire run lifetime, so the long run blew up with httpx.ReadTimeout and the outer except branch then released the dedupe key and emitted a false 'internal error' outbound. The GitHub channel's outbound send is log-only by design: agents post to the issue/PR via the gh CLI in the sandbox when they choose to comment or create a PR. There is nothing for the manager to ferry back, so the long-poll was pure overhead. This change adds ChannelRunPolicy.fire_and_forget (default False) and sets it True for the github channel. When fire_and_forget is True, _handle_chat dispatches via client.runs.create (short POST, returns once the run is pending) instead of client.runs.wait, and skips the response-extraction + outbound-publish block. ConflictError on a busy thread still trips the standard THREAD_BUSY_MESSAGE path so behavior on the busy case is preserved for any future non-github fire-and-forget channel. Other (non-github) channels are unchanged: their policy defaults fire_and_forget=False and they continue to dispatch via runs.wait. Adds 6 regression tests in tests/test_channels.py::TestGithubFireAndForget: - Default ChannelRunPolicy.fire_and_forget is False. - The github policy registers fire_and_forget=True. - github inbound calls runs.create, not runs.wait, with the right kwargs. - github inbound publishes no outbound on success. - ConflictError from runs.create still emits THREAD_BUSY_MESSAGE. - Non-github channels (slack) still dispatch via runs.wait. * test(lead-agent): accept user_id kwarg in skill-policy test stubs The two GitHub-channel tests added in #3754 stubbed _load_enabled_skills_for_tool_policy with a lambda that only accepted `available_skills` and `app_config`, but the real function (and its call site in agent.py) also passes `user_id`. This raised TypeError on every run, failing backend-unit-tests. Add `user_id=None` to match the three sibling stubs in the same file. * refactor(gateway): disambiguate context-key set names The two frozensets _INTERNAL_ONLY_CONTEXT_KEYS and _CONTEXT_ONLY_KEYS shared a confusable "CONTEXT_ONLY" token in different orders, and the first broke the _CONTEXT_<X>_KEYS pattern of its sibling _CONTEXT_CONFIGURABLE_KEYS. Rename to make the distinct axes explicit: _CONTEXT_INTERNAL_CALLER_KEYS - WHO: internal callers (scheduler) only _CONTEXT_RUNTIME_ONLY_KEYS - WHERE: runtime context only, never configurable Pure rename, no behavior change.
763 lines
32 KiB
Python
763 lines
32 KiB
Python
"""Tests for custom agent support."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
import yaml
|
||
from fastapi.testclient import TestClient
|
||
|
||
from deerflow.config.agents_api_config import AgentsApiConfig, get_agents_api_config, set_agents_api_config
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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 == tmp_path / "USER.md"
|
||
|
||
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_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
|
||
|
||
|
||
# ===========================================================================
|
||
# 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):
|
||
"""None agent_name should return global memory file."""
|
||
from deerflow.agents.memory.storage import FileMemoryStorage
|
||
from deerflow.config.memory_config import MemoryConfig
|
||
|
||
with (
|
||
patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)),
|
||
patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")),
|
||
):
|
||
storage = FileMemoryStorage()
|
||
path = storage._get_memory_file_path(None)
|
||
assert path == tmp_path / "memory.json"
|
||
|
||
def test_agent_memory_path(self, tmp_path):
|
||
"""Providing agent_name should return per-agent memory file."""
|
||
from deerflow.agents.memory.storage import FileMemoryStorage
|
||
from deerflow.config.memory_config import MemoryConfig
|
||
|
||
with (
|
||
patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)),
|
||
patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")),
|
||
):
|
||
storage = FileMemoryStorage()
|
||
path = storage._get_memory_file_path("code-reviewer")
|
||
assert path == tmp_path / "agents" / "code-reviewer" / "memory.json"
|
||
|
||
def test_different_paths_for_different_agents(self, tmp_path):
|
||
from deerflow.agents.memory.storage import FileMemoryStorage
|
||
from deerflow.config.memory_config import MemoryConfig
|
||
|
||
with (
|
||
patch("deerflow.agents.memory.storage.get_paths", return_value=_make_paths(tmp_path)),
|
||
patch("deerflow.agents.memory.storage.get_memory_config", return_value=MemoryConfig(storage_path="")),
|
||
):
|
||
storage = FileMemoryStorage()
|
||
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
|
||
assert path_global != path_b
|
||
assert path_a != path_b
|
||
|
||
|
||
# ===========================================================================
|
||
# 8. Gateway API – Agents endpoints
|
||
# ===========================================================================
|
||
|
||
|
||
def _make_test_app(tmp_path: Path):
|
||
"""Create a FastAPI app with the agents router, patching paths to tmp_path."""
|
||
from fastapi import FastAPI
|
||
|
||
from app.gateway.routers.agents import router
|
||
|
||
app = FastAPI()
|
||
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):
|
||
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_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_github_block(self, agent_client):
|
||
"""A hand-authored ``github:`` block on disk 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_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["github"] == {
|
||
"installation_id": 99999,
|
||
"bot_login": "github-agent-bot",
|
||
"bindings": [
|
||
{
|
||
"repo": "acme/widget",
|
||
"triggers": {"pull_request": {"actions": ["opened"]}},
|
||
}
|
||
],
|
||
}
|
||
|
||
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_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 disk
|
||
user_md = tmp_path / "USER.md"
|
||
assert user_md.exists()
|
||
assert user_md.read_text(encoding="utf-8") == content
|
||
|
||
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 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_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
|