deer-flow/backend/tests/test_custom_agent.py
Zheng Feng dcb2e687d5
feat(channels): add GitHub as a webhook-driven channel (#3754)
* 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.
2026-07-04 22:56:24 +08:00

763 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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