mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
438 lines
16 KiB
Python
438 lines
16 KiB
Python
"""Tests for per-job profile support in cron jobs.
|
|
|
|
Covers data-layer validation/storage, cronjob tool plumbing, scheduler runtime
|
|
HERMES_HOME scoping, and tick() serialization for profile jobs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def isolated_cron_profile_home(tmp_path, monkeypatch):
|
|
"""Create an isolated Hermes root with a named profile and temp cron store."""
|
|
root = tmp_path / "hermes-root"
|
|
profile_home = root / "profiles" / "support"
|
|
profile_home.mkdir(parents=True)
|
|
(root / "cron").mkdir(parents=True)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.setattr("cron.jobs.CRON_DIR", root / "cron")
|
|
monkeypatch.setattr("cron.jobs.JOBS_FILE", root / "cron" / "jobs.json")
|
|
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", root / "cron" / "output")
|
|
|
|
return root, profile_home
|
|
|
|
|
|
class TestNormalizeProfile:
|
|
def test_none_and_empty_return_none(self, isolated_cron_profile_home):
|
|
from cron.jobs import _normalize_profile
|
|
|
|
assert _normalize_profile(None) is None
|
|
assert _normalize_profile("") is None
|
|
assert _normalize_profile(" ") is None
|
|
|
|
def test_default_profile_is_valid_and_normalized(self, isolated_cron_profile_home):
|
|
from cron.jobs import _normalize_profile
|
|
|
|
assert _normalize_profile("Default") == "default"
|
|
|
|
def test_named_profile_must_exist_and_is_normalized(self, isolated_cron_profile_home):
|
|
from cron.jobs import _normalize_profile
|
|
|
|
assert _normalize_profile("Support") == "support"
|
|
|
|
def test_invalid_profile_name_is_rejected(self, isolated_cron_profile_home):
|
|
from cron.jobs import _normalize_profile
|
|
|
|
with pytest.raises(ValueError):
|
|
_normalize_profile("invalid!")
|
|
|
|
def test_missing_named_profile_is_rejected(self, isolated_cron_profile_home):
|
|
from cron.jobs import _normalize_profile
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
_normalize_profile("missing")
|
|
|
|
|
|
class TestCreateAndUpdateJobProfile:
|
|
def test_create_stores_profile_id(self, isolated_cron_profile_home):
|
|
from cron.jobs import create_job, get_job
|
|
|
|
job = create_job(prompt="hello", schedule="every 1h", profile="Support")
|
|
stored = get_job(job["id"])
|
|
|
|
assert stored is not None
|
|
assert stored["profile"] == "support"
|
|
|
|
def test_create_without_profile_preserves_old_behaviour(self, isolated_cron_profile_home):
|
|
from cron.jobs import create_job, get_job
|
|
|
|
job = create_job(prompt="hello", schedule="every 1h")
|
|
stored = get_job(job["id"])
|
|
|
|
assert stored is not None
|
|
assert stored.get("profile") is None
|
|
|
|
def test_create_accepts_explicit_default(self, isolated_cron_profile_home):
|
|
from cron.jobs import create_job, get_job
|
|
|
|
job = create_job(prompt="hello", schedule="every 1h", profile="default")
|
|
stored = get_job(job["id"])
|
|
|
|
assert stored is not None
|
|
assert stored["profile"] == "default"
|
|
|
|
def test_update_sets_and_clears_profile(self, isolated_cron_profile_home):
|
|
from cron.jobs import create_job, get_job, update_job
|
|
|
|
job = create_job(prompt="x", schedule="every 1h")
|
|
update_job(job["id"], {"profile": "Support"})
|
|
stored = get_job(job["id"])
|
|
assert stored is not None
|
|
assert stored["profile"] == "support"
|
|
|
|
update_job(job["id"], {"profile": ""})
|
|
stored = get_job(job["id"])
|
|
assert stored is not None
|
|
assert stored["profile"] is None
|
|
|
|
def test_update_rejects_missing_profile(self, isolated_cron_profile_home):
|
|
from cron.jobs import create_job, update_job
|
|
|
|
job = create_job(prompt="x", schedule="every 1h")
|
|
with pytest.raises(FileNotFoundError):
|
|
update_job(job["id"], {"profile": "missing"})
|
|
|
|
|
|
class TestCronjobToolProfile:
|
|
def test_create_and_list_with_profile(self, isolated_cron_profile_home):
|
|
from tools.cronjob_tools import cronjob
|
|
|
|
created = json.loads(
|
|
cronjob(
|
|
action="create",
|
|
prompt="hi",
|
|
schedule="every 1h",
|
|
profile="Support",
|
|
)
|
|
)
|
|
assert created["success"] is True
|
|
assert created["job"]["profile"] == "support"
|
|
|
|
listing = json.loads(cronjob(action="list"))
|
|
assert listing["jobs"][0]["profile"] == "support"
|
|
|
|
def test_update_clears_profile_with_empty_string(self, isolated_cron_profile_home):
|
|
from tools.cronjob_tools import cronjob
|
|
|
|
created = json.loads(
|
|
cronjob(
|
|
action="create",
|
|
prompt="hi",
|
|
schedule="every 1h",
|
|
profile="Support",
|
|
)
|
|
)
|
|
updated = json.loads(
|
|
cronjob(action="update", job_id=created["job_id"], profile="")
|
|
)
|
|
|
|
assert updated["success"] is True
|
|
assert "profile" not in updated["job"]
|
|
|
|
def test_schema_advertises_profile(self):
|
|
from tools.cronjob_tools import CRONJOB_SCHEMA
|
|
|
|
assert "profile" in CRONJOB_SCHEMA["parameters"]["properties"]
|
|
desc = CRONJOB_SCHEMA["parameters"]["properties"]["profile"]["description"]
|
|
desc_lower = desc.lower()
|
|
assert "hermes profile" in desc_lower
|
|
assert "context-local" in desc_lower
|
|
assert "subprocess" in desc_lower
|
|
assert "temporarily sets hermes_home" not in desc_lower
|
|
|
|
|
|
class TestRunJobProfileContext:
|
|
@staticmethod
|
|
def _install_agent_stubs(monkeypatch, observed: dict):
|
|
import sys
|
|
import cron.scheduler as sched
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
from hermes_constants import get_hermes_home
|
|
|
|
observed["env_home_during_init"] = os.environ.get("HERMES_HOME")
|
|
observed["profile_env_only_during_init"] = os.environ.get(
|
|
"HERMES_PROFILE_TEST_ONLY"
|
|
)
|
|
observed["profile_env_shared_during_init"] = os.environ.get(
|
|
"HERMES_PROFILE_TEST_SHARED"
|
|
)
|
|
observed["hermes_home_during_init"] = str(get_hermes_home())
|
|
observed["scheduler_home_during_init"] = str(sched._get_hermes_home())
|
|
observed["skip_context_files"] = kwargs.get("skip_context_files")
|
|
|
|
def run_conversation(self, *_a, **_kw):
|
|
from hermes_constants import get_hermes_home
|
|
|
|
observed["env_home_during_run"] = os.environ.get("HERMES_HOME")
|
|
observed["profile_env_only_during_run"] = os.environ.get(
|
|
"HERMES_PROFILE_TEST_ONLY"
|
|
)
|
|
observed["profile_env_shared_during_run"] = os.environ.get(
|
|
"HERMES_PROFILE_TEST_SHARED"
|
|
)
|
|
observed["hermes_home_during_run"] = str(get_hermes_home())
|
|
observed["scheduler_home_during_run"] = str(sched._get_hermes_home())
|
|
return {"final_response": "done", "messages": []}
|
|
|
|
def get_activity_summary(self):
|
|
return {"seconds_since_activity": 0.0}
|
|
|
|
def close(self):
|
|
observed["closed"] = True
|
|
|
|
fake_mod = type(sys)("run_agent")
|
|
fake_mod.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_mod)
|
|
|
|
from hermes_cli import runtime_provider as runtime_provider
|
|
|
|
monkeypatch.setattr(
|
|
runtime_provider,
|
|
"resolve_runtime_provider",
|
|
lambda **_kw: {
|
|
"provider": "test",
|
|
"api_key": "test-key",
|
|
"base_url": "http://test.local",
|
|
"api_mode": "chat_completions",
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi")
|
|
monkeypatch.setattr(sched, "_resolve_origin", lambda job: None)
|
|
monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None)
|
|
monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None)
|
|
monkeypatch.setattr(sched, "_hermes_home", None)
|
|
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")
|
|
|
|
import dotenv
|
|
|
|
def fake_load_dotenv(path, *_a, **_kw):
|
|
observed.setdefault("dotenv_paths", []).append(str(path))
|
|
return True
|
|
|
|
monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)
|
|
|
|
def test_run_job_sets_and_restores_profile_home(
|
|
self, isolated_cron_profile_home, monkeypatch
|
|
):
|
|
import cron.scheduler as sched
|
|
|
|
root, profile_home = isolated_cron_profile_home
|
|
observed: dict = {}
|
|
self._install_agent_stubs(monkeypatch, observed)
|
|
|
|
job = {
|
|
"id": "abc",
|
|
"name": "profile-job",
|
|
"profile": "support",
|
|
"schedule_display": "manual",
|
|
}
|
|
|
|
success, _output, response, error = sched.run_job(job)
|
|
|
|
assert success is True, f"run_job failed: error={error!r} response={response!r}"
|
|
assert observed["dotenv_paths"] == [str(profile_home / ".env")]
|
|
assert observed["env_home_during_init"] == str(root)
|
|
assert observed["env_home_during_run"] == str(root)
|
|
assert observed["hermes_home_during_init"] == str(profile_home.resolve())
|
|
assert observed["hermes_home_during_run"] == str(profile_home.resolve())
|
|
assert observed["scheduler_home_during_init"] == str(profile_home.resolve())
|
|
assert observed["scheduler_home_during_run"] == str(profile_home.resolve())
|
|
assert observed["skip_context_files"] is True
|
|
assert os.environ["HERMES_HOME"] == str(root)
|
|
assert sched._get_hermes_home() == root
|
|
|
|
def test_profile_dotenv_environment_is_restored(
|
|
self, isolated_cron_profile_home, monkeypatch
|
|
):
|
|
import dotenv
|
|
import cron.scheduler as sched
|
|
|
|
root, profile_home = isolated_cron_profile_home
|
|
observed: dict = {}
|
|
self._install_agent_stubs(monkeypatch, observed)
|
|
monkeypatch.setenv("HERMES_PROFILE_TEST_SHARED", "outer")
|
|
monkeypatch.delenv("HERMES_PROFILE_TEST_ONLY", raising=False)
|
|
|
|
def fake_load_dotenv(path, *_a, **_kw):
|
|
observed.setdefault("dotenv_paths", []).append(str(path))
|
|
os.environ["HERMES_PROFILE_TEST_SHARED"] = "profile-value"
|
|
os.environ["HERMES_PROFILE_TEST_ONLY"] = "profile-only"
|
|
os.environ["HERMES_CRON_TIMEOUT"] = "123"
|
|
return True
|
|
|
|
monkeypatch.setattr(dotenv, "load_dotenv", fake_load_dotenv)
|
|
|
|
job = {
|
|
"id": "env-profile",
|
|
"name": "profile-env-job",
|
|
"profile": "support",
|
|
"schedule_display": "manual",
|
|
}
|
|
|
|
success, _output, _response, error = sched.run_job(job)
|
|
|
|
assert success is True, error
|
|
assert observed["dotenv_paths"] == [str(profile_home / ".env")]
|
|
assert observed["profile_env_only_during_init"] == "profile-only"
|
|
assert observed["profile_env_shared_during_init"] == "profile-value"
|
|
assert observed["profile_env_only_during_run"] == "profile-only"
|
|
assert observed["profile_env_shared_during_run"] == "profile-value"
|
|
assert os.environ["HERMES_PROFILE_TEST_SHARED"] == "outer"
|
|
assert "HERMES_PROFILE_TEST_ONLY" not in os.environ
|
|
assert os.environ["HERMES_CRON_TIMEOUT"] == "0"
|
|
assert os.environ["HERMES_HOME"] == str(root)
|
|
assert sched._get_hermes_home() == root
|
|
|
|
def test_no_agent_profile_uses_profile_scripts_dir_and_restores_env(
|
|
self, isolated_cron_profile_home, monkeypatch
|
|
):
|
|
import cron.scheduler as sched
|
|
|
|
root, profile_home = isolated_cron_profile_home
|
|
scripts_dir = profile_home / "scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "print_home.py").write_text(
|
|
"import os\nprint(os.environ.get('HERMES_HOME', ''))\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(sched, "_hermes_home", None)
|
|
|
|
job = {
|
|
"id": "script1",
|
|
"name": "profile-script",
|
|
"profile": "support",
|
|
"script": "print_home.py",
|
|
"no_agent": True,
|
|
}
|
|
|
|
success, _doc, response, error = sched.run_job(job)
|
|
|
|
assert success is True, error
|
|
assert response.strip() == str(profile_home.resolve())
|
|
assert os.environ["HERMES_HOME"] == str(root)
|
|
assert sched._get_hermes_home() == root
|
|
|
|
def test_run_job_without_profile_leaves_hermes_home_untouched(
|
|
self, isolated_cron_profile_home, monkeypatch
|
|
):
|
|
import cron.scheduler as sched
|
|
|
|
root, _profile_home = isolated_cron_profile_home
|
|
observed: dict = {}
|
|
self._install_agent_stubs(monkeypatch, observed)
|
|
|
|
job = {
|
|
"id": "noprof",
|
|
"name": "no-profile-job",
|
|
"profile": None,
|
|
"schedule_display": "manual",
|
|
}
|
|
|
|
success, *_ = sched.run_job(job)
|
|
|
|
assert success is True
|
|
assert observed["hermes_home_during_init"] == str(root)
|
|
assert os.environ["HERMES_HOME"] == str(root)
|
|
|
|
def test_run_job_falls_back_on_missing_runtime_profile(
|
|
self, isolated_cron_profile_home, monkeypatch
|
|
):
|
|
import cron.scheduler as sched
|
|
|
|
root, _profile_home = isolated_cron_profile_home
|
|
observed: dict = {}
|
|
self._install_agent_stubs(monkeypatch, observed)
|
|
|
|
job = {
|
|
"id": "missing-profile",
|
|
"name": "missing-profile-job",
|
|
"profile": "missing",
|
|
"schedule_display": "manual",
|
|
}
|
|
|
|
# Should succeed with fallback, not raise
|
|
success, _output, response, error = sched.run_job(job)
|
|
|
|
assert success is True, f"run_job should fallback, not fail: error={error!r}"
|
|
# Verify it used the default home, not the missing profile
|
|
assert observed["hermes_home_during_init"] == str(root)
|
|
assert os.environ["HERMES_HOME"] == str(root)
|
|
|
|
|
|
class TestTickProfilePartition:
|
|
def test_profile_and_workdir_combined(self, isolated_cron_profile_home, monkeypatch):
|
|
"""Both profile and workdir set — verify both are applied and restored."""
|
|
import cron.scheduler as sched
|
|
|
|
root, profile_home = isolated_cron_profile_home
|
|
observed: dict = {}
|
|
TestRunJobProfileContext._install_agent_stubs(monkeypatch, observed)
|
|
fake_workdir = str(root / "myproject")
|
|
(root / "myproject").mkdir()
|
|
|
|
job = {
|
|
"id": "combo",
|
|
"name": "combo-job",
|
|
"profile": "support",
|
|
"workdir": fake_workdir,
|
|
"schedule_display": "manual",
|
|
}
|
|
|
|
success, _output, _response, error = sched.run_job(job)
|
|
|
|
assert success is True, error
|
|
assert observed["hermes_home_during_init"] == str(profile_home.resolve())
|
|
assert os.environ.get("TERMINAL_CWD", "") != fake_workdir, \
|
|
"TERMINAL_CWD should be restored after job"
|
|
assert os.environ["HERMES_HOME"] == str(root)
|
|
assert sched._get_hermes_home() == root
|
|
|
|
def test_profile_jobs_run_sequentially(self, isolated_cron_profile_home, monkeypatch):
|
|
import threading
|
|
import cron.scheduler as sched
|
|
|
|
profile_job = {"id": "a", "name": "A", "profile": "default"}
|
|
parallel_job = {"id": "b", "name": "B", "profile": None}
|
|
|
|
monkeypatch.setattr(sched, "get_due_jobs", lambda: [profile_job, parallel_job])
|
|
monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None)
|
|
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
def fake_run_job(job):
|
|
calls.append((job["id"], threading.current_thread().name))
|
|
return True, "output", "response", None
|
|
|
|
monkeypatch.setattr(sched, "run_job", fake_run_job)
|
|
monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None)
|
|
monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None)
|
|
monkeypatch.setattr(sched, "_deliver_result", lambda *_a, **_kw: None)
|
|
|
|
n = sched.tick(verbose=False)
|
|
|
|
assert n == 2
|
|
ids = [job_id for job_id, _thread_name in calls]
|
|
assert ids.index("a") < ids.index("b")
|
|
main_thread_name = threading.current_thread().name
|
|
profile_thread_name = next(thread for job_id, thread in calls if job_id == "a")
|
|
assert profile_thread_name == main_thread_name
|