from __future__ import annotations import json import threading import pytest from deerflow.config import skill_evolution_runtime from deerflow.skills import curator_log @pytest.fixture def overrides_path(tmp_path, monkeypatch): """Redirect skill_evolution_runtime.resolve_path at a fresh temp file.""" target = tmp_path / "skill_evolution.json" monkeypatch.setenv("DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH", str(target)) yield target def test_load_overrides_returns_none_when_missing(overrides_path): assert not overrides_path.exists() assert skill_evolution_runtime.load_overrides() is None def test_save_and_load_overrides_roundtrip(overrides_path): payload = { "enabled": True, "curator": { "enabled": True, "interval_hours": 12, "stale_after_days": 7, "archive_after_days": 30, "model_name": "curator-mini", }, } written = skill_evolution_runtime.save_overrides(payload) assert written == overrides_path assert overrides_path.exists() assert skill_evolution_runtime.load_overrides() == payload def test_load_overrides_handles_invalid_json(overrides_path): overrides_path.write_text("{not valid json", encoding="utf-8") assert skill_evolution_runtime.load_overrides() is None def test_load_overrides_rejects_non_object_root(overrides_path): overrides_path.write_text(json.dumps([1, 2, 3]), encoding="utf-8") assert skill_evolution_runtime.load_overrides() is None def test_app_config_merges_overrides_on_from_file(overrides_path, monkeypatch, tmp_path): yaml_cfg = tmp_path / "config.yaml" yaml_cfg.write_text( """ sandbox: use: deerflow.sandbox.local:LocalSandboxProvider skill_evolution: enabled: false curator: interval_hours: 168 stale_after_days: 30 """.strip(), encoding="utf-8", ) # Overrides flip enabled and change interval_hours. skill_evolution_runtime.save_overrides( { "enabled": True, "curator": {"interval_hours": 24}, } ) from deerflow.config.app_config import AppConfig cfg = AppConfig.from_file(str(yaml_cfg)) assert cfg.skill_evolution.enabled is True assert cfg.skill_evolution.curator.interval_hours == 24 # Untouched yaml values remain. assert cfg.skill_evolution.curator.stale_after_days == 30 def test_curator_log_start_new_run_resets_and_seeds_record(): curator_log.clear() curator_log.record("info", "old record") assert len(curator_log.get_recent()) == 1 curator_log.start_new_run() records = curator_log.get_recent() assert len(records) == 1 assert records[0]["message"] == "run_started" assert records[0]["level"] == "info" def test_curator_log_record_is_thread_safe(): curator_log.clear() def writer(idx: int) -> None: for i in range(20): curator_log.record("info", f"thread-{idx}-{i}") threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)] for t in threads: t.start() for t in threads: t.join() records = curator_log.get_recent() # 5 threads * 20 records each = 100, well under the 200 ring-buffer cap. assert len(records) == 100 assert all("message" in r and "ts" in r for r in records) def test_curator_log_record_ignores_internal_errors(monkeypatch): # Even if the timestamp helper blows up, record() must not raise. monkeypatch.setattr(curator_log, "_now_iso", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) curator_log.record("info", "should not raise")