deer-flow/backend/tests/test_skill_manage_tool.py
greatmengqi edf345cd72 refactor(config): eliminate global mutable state, wire DeerFlowContext into runtime
- Freeze all config models (AppConfig + 15 sub-configs) with frozen=True
- Purify from_file() — remove 9 load_*_from_dict() side-effect calls
- Replace mtime/reload/push/pop machinery with single ContextVar + init_app_config()
- Delete 10 sub-module globals and their getters/setters/loaders
- Migrate 50+ consumers from get_*_config() to get_app_config().xxx

- Expand DeerFlowContext: app_config + thread_id + agent_name (frozen dataclass)
- Wire into Gateway runtime (worker.py) and DeerFlowClient via context= parameter
- Remove sandbox_id from runtime.context — flows through ThreadState.sandbox only
- Middleware/tools access runtime.context directly via Runtime[DeerFlowContext] generic
- resolve_context() retained at server entry points for LangGraph Server fallback
2026-04-14 01:18:19 +08:00

187 lines
7.0 KiB
Python

import importlib
from types import SimpleNamespace
import anyio
import pytest
from deerflow.config.app_config import AppConfig
from deerflow.config.deer_flow_context import DeerFlowContext
from deerflow.config.sandbox_config import SandboxConfig
skill_manage_module = importlib.import_module("deerflow.tools.skill_manage_tool")
def _make_context(thread_id: str) -> DeerFlowContext:
return DeerFlowContext(
app_config=AppConfig(sandbox=SandboxConfig(use="test")),
thread_id=thread_id,
)
def _skill_content(name: str, description: str = "Demo skill") -> str:
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
async def _async_result(decision: str, reason: str):
from deerflow.skills.security_scanner import ScanResult
return ScanResult(decision=decision, reason=reason)
def test_skill_manage_create_and_patch(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config))
refresh_calls = []
async def _refresh():
refresh_calls.append("refresh")
monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok"))
runtime = SimpleNamespace(context=_make_context("thread-1"), config={"configurable": {"thread_id": "thread-1"}})
result = anyio.run(
skill_manage_module.skill_manage_tool.coroutine,
runtime,
"create",
"demo-skill",
_skill_content("demo-skill"),
)
assert "Created custom skill" in result
patch_result = anyio.run(
skill_manage_module.skill_manage_tool.coroutine,
runtime,
"patch",
"demo-skill",
None,
None,
"Demo skill",
"Patched skill",
1,
)
assert "Patched custom skill" in patch_result
assert "Patched skill" in (skills_root / "custom" / "demo-skill" / "SKILL.md").read_text(encoding="utf-8")
assert refresh_calls == ["refresh", "refresh"]
def test_skill_manage_patch_replaces_single_occurrence_by_default(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config))
async def _refresh():
return None
monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok"))
runtime = SimpleNamespace(context=_make_context("thread-1"), config={"configurable": {"thread_id": "thread-1"}})
content = _skill_content("demo-skill", "Demo skill") + "\nRepeated: Demo skill\n"
anyio.run(skill_manage_module.skill_manage_tool.coroutine, runtime, "create", "demo-skill", content)
patch_result = anyio.run(
skill_manage_module.skill_manage_tool.coroutine,
runtime,
"patch",
"demo-skill",
None,
None,
"Demo skill",
"Patched skill",
)
skill_text = (skills_root / "custom" / "demo-skill" / "SKILL.md").read_text(encoding="utf-8")
assert "1 replacement(s) applied, 2 match(es) found" in patch_result
assert skill_text.count("Patched skill") == 1
assert skill_text.count("Demo skill") == 1
def test_skill_manage_rejects_public_skill_patch(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
public_dir = skills_root / "public" / "deep-research"
public_dir.mkdir(parents=True, exist_ok=True)
(public_dir / "SKILL.md").write_text(_skill_content("deep-research"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config))
runtime = SimpleNamespace(context=_make_context(""), config={"configurable": {}})
with pytest.raises(ValueError, match="built-in skill"):
anyio.run(
skill_manage_module.skill_manage_tool.coroutine,
runtime,
"patch",
"deep-research",
None,
None,
"Demo skill",
"Patched",
)
def test_skill_manage_sync_wrapper_supported(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config))
refresh_calls = []
async def _refresh():
refresh_calls.append("refresh")
monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok"))
runtime = SimpleNamespace(context=_make_context("thread-sync"), config={"configurable": {"thread_id": "thread-sync"}})
result = skill_manage_module.skill_manage_tool.func(
runtime=runtime,
action="create",
name="sync-skill",
content=_skill_content("sync-skill"),
)
assert "Created custom skill" in result
assert refresh_calls == ["refresh"]
def test_skill_manage_rejects_support_path_traversal(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(AppConfig, "current", staticmethod(lambda: config))
async def _refresh():
return None
monkeypatch.setattr(skill_manage_module, "refresh_skills_system_prompt_cache_async", _refresh)
monkeypatch.setattr(skill_manage_module, "scan_skill_content", lambda *args, **kwargs: _async_result("allow", "ok"))
runtime = SimpleNamespace(context=_make_context("thread-1"), config={"configurable": {"thread_id": "thread-1"}})
anyio.run(skill_manage_module.skill_manage_tool.coroutine, runtime, "create", "demo-skill", _skill_content("demo-skill"))
with pytest.raises(ValueError, match="parent-directory traversal|selected support directory"):
anyio.run(
skill_manage_module.skill_manage_tool.coroutine,
runtime,
"write_file",
"demo-skill",
"malicious overwrite",
"references/../SKILL.md",
)