mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
253 lines
9.8 KiB
Python
253 lines
9.8 KiB
Python
"""Tests for per-job workdir support in cron jobs.
|
|
|
|
Covers:
|
|
- jobs.create_job: param plumbing, validation, default-None preserved
|
|
- jobs._normalize_workdir: absolute / relative / missing / file-not-dir
|
|
- jobs.update_job: set, clear, re-validate
|
|
- tools.cronjob_tools.cronjob: create + update JSON round-trip, schema
|
|
includes workdir, _format_job exposes it when set
|
|
- scheduler.tick(): partitions workdir jobs off the thread pool, restores
|
|
TERMINAL_CWD in finally, honours the env override during run_job
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def tmp_cron_dir(tmp_path, monkeypatch):
|
|
"""Isolate cron job storage into a temp dir so tests don't stomp on real jobs."""
|
|
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
|
|
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
|
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
|
|
return tmp_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# jobs._normalize_workdir
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNormalizeWorkdir:
|
|
def test_none_returns_none(self):
|
|
from cron.jobs import _normalize_workdir
|
|
assert _normalize_workdir(None) is None
|
|
|
|
def test_empty_string_returns_none(self):
|
|
from cron.jobs import _normalize_workdir
|
|
assert _normalize_workdir("") is None
|
|
assert _normalize_workdir(" ") is None
|
|
|
|
def test_absolute_existing_dir_returns_resolved_str(self, tmp_path):
|
|
from cron.jobs import _normalize_workdir
|
|
result = _normalize_workdir(str(tmp_path))
|
|
assert result == str(tmp_path.resolve())
|
|
|
|
def test_tilde_expands(self, tmp_path, monkeypatch):
|
|
from cron.jobs import _normalize_workdir
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
result = _normalize_workdir("~")
|
|
assert result == str(tmp_path.resolve())
|
|
|
|
def test_relative_path_rejected(self):
|
|
from cron.jobs import _normalize_workdir
|
|
with pytest.raises(ValueError, match="absolute path"):
|
|
_normalize_workdir("some/relative/path")
|
|
|
|
def test_missing_dir_rejected(self, tmp_path):
|
|
from cron.jobs import _normalize_workdir
|
|
missing = tmp_path / "does-not-exist"
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
_normalize_workdir(str(missing))
|
|
|
|
def test_file_not_dir_rejected(self, tmp_path):
|
|
from cron.jobs import _normalize_workdir
|
|
f = tmp_path / "file.txt"
|
|
f.write_text("hi")
|
|
with pytest.raises(ValueError, match="not a directory"):
|
|
_normalize_workdir(str(f))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# jobs.create_job and update_job
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCreateJobWorkdir:
|
|
def test_workdir_stored_when_set(self, tmp_cron_dir):
|
|
from cron.jobs import create_job, get_job
|
|
job = create_job(
|
|
prompt="hello",
|
|
schedule="every 1h",
|
|
workdir=str(tmp_cron_dir),
|
|
)
|
|
stored = get_job(job["id"])
|
|
assert stored["workdir"] == str(tmp_cron_dir.resolve())
|
|
|
|
|
|
def test_create_rejects_invalid_workdir(self, tmp_cron_dir):
|
|
from cron.jobs import create_job
|
|
with pytest.raises(ValueError):
|
|
create_job(
|
|
prompt="hello",
|
|
schedule="every 1h",
|
|
workdir="not/absolute",
|
|
)
|
|
|
|
|
|
class TestUpdateJobWorkdir:
|
|
def test_set_workdir_via_update(self, tmp_cron_dir):
|
|
from cron.jobs import create_job, get_job, update_job
|
|
job = create_job(prompt="x", schedule="every 1h")
|
|
update_job(job["id"], {"workdir": str(tmp_cron_dir)})
|
|
assert get_job(job["id"])["workdir"] == str(tmp_cron_dir.resolve())
|
|
|
|
def test_clear_workdir_with_none(self, tmp_cron_dir):
|
|
from cron.jobs import create_job, get_job, update_job
|
|
job = create_job(
|
|
prompt="x", schedule="every 1h", workdir=str(tmp_cron_dir)
|
|
)
|
|
update_job(job["id"], {"workdir": None})
|
|
assert get_job(job["id"])["workdir"] is None
|
|
|
|
|
|
def test_update_rejects_invalid_workdir(self, tmp_cron_dir):
|
|
from cron.jobs import create_job, update_job
|
|
job = create_job(prompt="x", schedule="every 1h")
|
|
with pytest.raises(ValueError):
|
|
update_job(job["id"], {"workdir": "nope/relative"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tools.cronjob_tools: end-to-end JSON round-trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCronjobToolWorkdir:
|
|
|
|
|
|
def test_schema_advertises_workdir(self):
|
|
from tools.cronjob_tools import CRONJOB_SCHEMA
|
|
assert "workdir" in CRONJOB_SCHEMA["parameters"]["properties"]
|
|
desc = CRONJOB_SCHEMA["parameters"]["properties"]["workdir"]["description"]
|
|
assert "absolute" in desc.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# scheduler.tick(): workdir partition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTickWorkdirPartition:
|
|
"""
|
|
tick() must run workdir jobs sequentially (outside the ThreadPoolExecutor)
|
|
because run_job mutates os.environ["TERMINAL_CWD"], which is process-global.
|
|
We verify the partition without booting the real scheduler by patching the
|
|
pieces tick() calls.
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# scheduler.run_job: TERMINAL_CWD + skip_context_files wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRunJobTerminalCwd:
|
|
"""
|
|
run_job sets TERMINAL_CWD + flips skip_context_files=False when workdir
|
|
is set, and restores the prior TERMINAL_CWD in finally — even on error.
|
|
We stub AIAgent so no real API call happens.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _install_stubs(monkeypatch, observed: dict):
|
|
"""Patch enough of run_job's deps that it executes without real creds."""
|
|
import os
|
|
import sys
|
|
import cron.scheduler as sched
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
observed["skip_context_files"] = kwargs.get("skip_context_files")
|
|
observed["load_soul_identity"] = kwargs.get("load_soul_identity")
|
|
observed["terminal_cwd_during_init"] = os.environ.get(
|
|
"TERMINAL_CWD", "_UNSET_"
|
|
)
|
|
|
|
def run_conversation(self, *_a, **_kw):
|
|
observed["terminal_cwd_during_run"] = os.environ.get(
|
|
"TERMINAL_CWD", "_UNSET_"
|
|
)
|
|
return {"final_response": "done", "messages": []}
|
|
|
|
def get_activity_summary(self):
|
|
return {"seconds_since_activity": 0.0}
|
|
|
|
fake_mod = type(sys)("run_agent")
|
|
fake_mod.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_mod)
|
|
|
|
# Bypass the real provider resolver — it reads ~/.hermes and credentials.
|
|
from hermes_cli import runtime_provider as _rtp
|
|
monkeypatch.setattr(
|
|
_rtp,
|
|
"resolve_runtime_provider",
|
|
lambda **_kw: {
|
|
"provider": "test",
|
|
"api_key": "k",
|
|
"base_url": "http://test.local",
|
|
"api_mode": "chat_completions",
|
|
},
|
|
)
|
|
|
|
# Stub scheduler helpers that would otherwise hit the filesystem / config.
|
|
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)
|
|
# Unlimited inactivity so the poll loop returns immediately.
|
|
monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0")
|
|
|
|
# run_job calls load_dotenv(~/.hermes/.env, override=True), which will
|
|
# happily clobber TERMINAL_CWD out from under us if the real user .env
|
|
# has TERMINAL_CWD set (common on dev boxes). Stub it out.
|
|
import dotenv
|
|
monkeypatch.setattr(dotenv, "load_dotenv", lambda *_a, **_kw: True)
|
|
|
|
|
|
def test_no_workdir_leaves_terminal_cwd_untouched(self, monkeypatch):
|
|
"""When workdir is absent, run_job must not touch TERMINAL_CWD at all —
|
|
whatever value was present before the call should be present after.
|
|
|
|
We don't assert on the *content* of TERMINAL_CWD (other tests in the
|
|
same xdist worker may leave it set to something like '.'); we just
|
|
check it's unchanged by run_job.
|
|
"""
|
|
import os
|
|
import cron.scheduler as sched
|
|
|
|
# Pin TERMINAL_CWD to a sentinel via monkeypatch so we control both
|
|
# the before-value and the after-value regardless of cross-test state.
|
|
monkeypatch.setenv("TERMINAL_CWD", "/cron-test-sentinel")
|
|
before = os.environ["TERMINAL_CWD"]
|
|
|
|
observed: dict = {}
|
|
self._install_stubs(monkeypatch, observed)
|
|
|
|
job = {
|
|
"id": "xyz",
|
|
"name": "no-wd-job",
|
|
"workdir": None,
|
|
"schedule_display": "manual",
|
|
}
|
|
|
|
success, *_ = sched.run_job(job)
|
|
assert success is True
|
|
|
|
# Feature is OFF — skip_context_files stays True.
|
|
assert observed["skip_context_files"] is True
|
|
# Cron still forces SOUL.md identity even when cwd context files stay off.
|
|
assert observed["load_soul_identity"] is True
|
|
# TERMINAL_CWD saw the same value during init as it had before.
|
|
assert observed["terminal_cwd_during_init"] == before
|
|
# And after run_job completes, it's still the sentinel (nothing
|
|
# overwrote or cleared it).
|
|
assert os.environ["TERMINAL_CWD"] == before
|