Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -1836,3 +1836,55 @@ class TestResolveMessagesMaxTokens:
|
||||
result = _resolve_anthropic_messages_max_tokens(0.5, "claude-opus-4-6")
|
||||
assert result > 0
|
||||
assert result != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# convert_tools_to_anthropic — tool dedup at API boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConvertToolsToAnthropicDedup:
|
||||
"""convert_tools_to_anthropic must deduplicate tool names.
|
||||
|
||||
Anthropic rejects requests with duplicate tool names. This guard converts
|
||||
a hard failure into a warning log. See:
|
||||
https://github.com/NousResearch/hermes-agent/issues/18478
|
||||
"""
|
||||
|
||||
def _make_openai_tool(self, name: str) -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": f"Tool {name}",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
def test_unique_tools_pass_through(self):
|
||||
tools = [self._make_openai_tool("alpha"), self._make_openai_tool("beta")]
|
||||
result = convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 2
|
||||
names = [t["name"] for t in result]
|
||||
assert names == ["alpha", "beta"]
|
||||
|
||||
def test_duplicate_tool_names_are_deduplicated(self):
|
||||
"""RED test — must fail until dedup guard is added."""
|
||||
tools = [
|
||||
self._make_openai_tool("lcm_grep"),
|
||||
self._make_openai_tool("lcm_describe"),
|
||||
self._make_openai_tool("lcm_grep"), # duplicate
|
||||
self._make_openai_tool("lcm_expand"),
|
||||
self._make_openai_tool("lcm_describe"), # duplicate
|
||||
]
|
||||
result = convert_tools_to_anthropic(tools)
|
||||
names = [t["name"] for t in result]
|
||||
assert len(names) == len(set(names)), (
|
||||
f"Duplicate tool names found: {names}"
|
||||
)
|
||||
assert len(result) == 3 # lcm_grep, lcm_describe, lcm_expand
|
||||
|
||||
def test_empty_tools_returns_empty(self):
|
||||
assert convert_tools_to_anthropic([]) == []
|
||||
|
||||
def test_none_tools_returns_empty(self):
|
||||
assert convert_tools_to_anthropic(None) == []
|
||||
|
||||
@@ -16,6 +16,7 @@ from agent.auxiliary_client import (
|
||||
auxiliary_max_tokens_param,
|
||||
call_llm,
|
||||
async_call_llm,
|
||||
_build_call_kwargs,
|
||||
_read_codex_access_token,
|
||||
_get_provider_chain,
|
||||
_is_payment_error,
|
||||
@@ -1752,3 +1753,143 @@ class TestVisionAutoSkipsKimiCoding:
|
||||
"kimi-coding",
|
||||
"kimi-coding-cn",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_call_kwargs — tool dedup at API boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildCallKwargsToolDedup:
|
||||
"""_build_call_kwargs must deduplicate tool names before passing to API.
|
||||
|
||||
Providers like Google Vertex, Azure, and Bedrock reject requests with
|
||||
duplicate tool names (HTTP 400). This guard converts a hard failure into
|
||||
a warning log so agent turns succeed even if an upstream injection path
|
||||
regresses. See: https://github.com/NousResearch/hermes-agent/issues/18478
|
||||
"""
|
||||
|
||||
def _make_tool(self, name: str) -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": f"Tool {name}",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
def test_unique_tools_pass_through_unchanged(self):
|
||||
tools = [self._make_tool("alpha"), self._make_tool("beta")]
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="openai", model="gpt-4o", messages=[], tools=tools,
|
||||
)
|
||||
assert len(kwargs["tools"]) == 2
|
||||
names = [t["function"]["name"] for t in kwargs["tools"]]
|
||||
assert names == ["alpha", "beta"]
|
||||
|
||||
def test_duplicate_tool_names_are_deduplicated(self):
|
||||
"""RED test — must fail until dedup guard is added."""
|
||||
tools = [
|
||||
self._make_tool("lcm_grep"),
|
||||
self._make_tool("lcm_describe"),
|
||||
self._make_tool("lcm_grep"), # duplicate
|
||||
self._make_tool("lcm_expand"),
|
||||
self._make_tool("lcm_describe"), # duplicate
|
||||
]
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="google", model="gemini-2.5-pro", messages=[], tools=tools,
|
||||
)
|
||||
result_tools = kwargs["tools"]
|
||||
names = [t["function"]["name"] for t in result_tools]
|
||||
# Must be deduplicated — no repeated names
|
||||
assert len(names) == len(set(names)), (
|
||||
f"Duplicate tool names found: {names}"
|
||||
)
|
||||
assert len(result_tools) == 3 # lcm_grep, lcm_describe, lcm_expand
|
||||
|
||||
def test_empty_tools_unchanged(self):
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="openai", model="gpt-4o", messages=[], tools=[],
|
||||
)
|
||||
assert kwargs.get("tools") == [] or "tools" not in kwargs
|
||||
|
||||
def test_none_tools_unchanged(self):
|
||||
kwargs = _build_call_kwargs(
|
||||
provider="openai", model="gpt-4o", messages=[], tools=None,
|
||||
)
|
||||
assert "tools" not in kwargs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
"""Strip provider env vars so each test starts clean."""
|
||||
for key in (
|
||||
"OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
class TestOpenRouterExplicitApiKey:
|
||||
"""Test that explicit_api_key is correctly propagated to _try_openrouter()."""
|
||||
|
||||
def test_resolve_provider_client_passes_explicit_api_key_to_openrouter(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""
|
||||
When resolve_provider_client() is called with explicit_api_key for OpenRouter,
|
||||
the explicit key should be passed to the OpenAI client instead of falling back
|
||||
to OPENROUTER_API_KEY env var.
|
||||
"""
|
||||
# Set up env var as fallback (should NOT be used when explicit_api_key is provided)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "env-fallback-key")
|
||||
|
||||
# Mock OpenAI to capture the api_key used
|
||||
mock_openai = MagicMock()
|
||||
mock_openai.return_value = MagicMock(name="openrouter-client")
|
||||
|
||||
with patch("agent.auxiliary_client.OpenAI", mock_openai):
|
||||
client, model = resolve_provider_client(
|
||||
provider="openrouter",
|
||||
explicit_api_key="explicit-pool-key",
|
||||
)
|
||||
|
||||
# Verify a client was created
|
||||
assert client is not None
|
||||
# Verify the explicit key was used, not the env var fallback
|
||||
mock_openai.assert_called_once()
|
||||
call_kwargs = mock_openai.call_args[1]
|
||||
assert call_kwargs["api_key"] == "explicit-pool-key", (
|
||||
f"Expected explicit_api_key to be passed, got: {call_kwargs['api_key']}"
|
||||
)
|
||||
assert call_kwargs["api_key"] != "env-fallback-key", (
|
||||
"Should NOT fall back to OPENROUTER_API_KEY when explicit_api_key is provided"
|
||||
)
|
||||
|
||||
def test_resolve_provider_client_without_explicit_api_key_falls_back_to_env(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""
|
||||
When resolve_provider_client() is called WITHOUT explicit_api_key for OpenRouter,
|
||||
it should fall back to OPENROUTER_API_KEY env var.
|
||||
"""
|
||||
# Set up env var as fallback (should be used when explicit_api_key is NOT provided)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "env-fallback-key")
|
||||
|
||||
# Mock OpenAI to capture the api_key used
|
||||
mock_openai = MagicMock()
|
||||
mock_openai.return_value = MagicMock(name="openrouter-client")
|
||||
|
||||
with patch("agent.auxiliary_client.OpenAI", mock_openai):
|
||||
client, model = resolve_provider_client(
|
||||
provider="openrouter",
|
||||
explicit_api_key=None,
|
||||
)
|
||||
|
||||
# Verify a client was created
|
||||
assert client is not None
|
||||
# Verify the env var fallback was used
|
||||
mock_openai.assert_called_once()
|
||||
call_kwargs = mock_openai.call_args[1]
|
||||
assert call_kwargs["api_key"] == "env-fallback-key", (
|
||||
f"Expected env fallback key to be used when explicit_api_key is None, got: {call_kwargs['api_key']}"
|
||||
)
|
||||
|
||||
@@ -348,6 +348,64 @@ def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch):
|
||||
assert entry.access_token == "sk-or-seeded"
|
||||
|
||||
|
||||
|
||||
def test_load_pool_prefers_dotenv_over_stale_os_environ(tmp_path, monkeypatch):
|
||||
"""Regression for #18254: stale OPENROUTER_API_KEY in os.environ (inherited
|
||||
from a parent shell) must NOT shadow the fresh key in ~/.hermes/.env when
|
||||
seeding the credential pool. Before the fix, `get_env_value()` preferred
|
||||
os.environ and silently wrote the stale value into auth.json, causing
|
||||
persistent 401 errors after key rotation.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Simulate the bug: parent shell exported a stale test key
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-STALE-from-shell")
|
||||
|
||||
# User edited ~/.hermes/.env with the fresh key
|
||||
(hermes_home / ".env").write_text(
|
||||
"OPENROUTER_API_KEY=sk-or-FRESH-from-dotenv\n"
|
||||
)
|
||||
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.source == "env:OPENROUTER_API_KEY"
|
||||
# The fresh key from .env must win over the stale shell export
|
||||
assert entry.access_token == "sk-or-FRESH-from-dotenv", (
|
||||
f"Expected .env to win, got {entry.access_token!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_pool_falls_back_to_os_environ_when_dotenv_empty(tmp_path, monkeypatch):
|
||||
"""When ~/.hermes/.env does not define OPENROUTER_API_KEY (typical Docker /
|
||||
K8s / systemd deployment), seeding must still pick up the key from
|
||||
os.environ. Guards against regressions that would break production
|
||||
deployments relying on runtime-injected env vars.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-from-runtime-env")
|
||||
|
||||
# .env exists but does not define OPENROUTER_API_KEY
|
||||
(hermes_home / ".env").write_text("SOME_OTHER_VAR=unrelated\n")
|
||||
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.access_token == "sk-or-from-runtime-env"
|
||||
|
||||
|
||||
def test_load_pool_removes_stale_seeded_env_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
@@ -314,3 +314,281 @@ def test_dry_run_skips_snapshot(backup_env, monkeypatch):
|
||||
assert not any(r.get("reason") == "pre-curator-run" for r in rows), (
|
||||
"dry-run must not create a pre-run snapshot"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cron-jobs backup + rollback (the part issue #18671's follow-up adds)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_cron_jobs(home: Path, jobs: list) -> Path:
|
||||
"""Write a synthetic cron/jobs.json under HERMES_HOME. Returns the path.
|
||||
Mirrors cron.jobs.save_jobs() wrapper shape: `{"jobs": [...], "updated_at": ...}`.
|
||||
"""
|
||||
cron_dir = home / "cron"
|
||||
cron_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = cron_dir / "jobs.json"
|
||||
path.write_text(
|
||||
json.dumps({"jobs": jobs, "updated_at": "2026-05-01T00:00:00Z"}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _reload_cron_jobs(home: Path):
|
||||
"""Reload cron.jobs so its module-level HERMES_DIR picks up the tmp HOME."""
|
||||
import hermes_constants
|
||||
importlib.reload(hermes_constants)
|
||||
if "cron.jobs" in sys.modules:
|
||||
import cron.jobs as _cj
|
||||
importlib.reload(_cj)
|
||||
else:
|
||||
import cron.jobs as _cj # noqa: F401
|
||||
import cron.jobs as cj
|
||||
return cj
|
||||
|
||||
|
||||
def test_snapshot_includes_cron_jobs(backup_env):
|
||||
"""With a cron/jobs.json present, snapshot writes cron-jobs.json and records it in manifest."""
|
||||
cb = backup_env["cb"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
_write_cron_jobs(backup_env["home"], [
|
||||
{"id": "job-a", "name": "a", "schedule": "every 1h", "skills": ["alpha"]},
|
||||
{"id": "job-b", "name": "b", "schedule": "every 2h", "skill": "alpha"},
|
||||
])
|
||||
|
||||
snap = cb.snapshot_skills(reason="test")
|
||||
assert snap is not None
|
||||
assert (snap / cb.CRON_JOBS_FILENAME).exists()
|
||||
|
||||
mf = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert mf["cron_jobs"]["backed_up"] is True
|
||||
assert mf["cron_jobs"]["jobs_count"] == 2
|
||||
|
||||
|
||||
def test_snapshot_without_cron_jobs_file_still_succeeds(backup_env):
|
||||
"""No cron/jobs.json on disk → snapshot succeeds, manifest records absence."""
|
||||
cb = backup_env["cb"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
# Deliberately do not create ~/.hermes/cron/jobs.json
|
||||
|
||||
snap = cb.snapshot_skills(reason="test")
|
||||
assert snap is not None
|
||||
assert not (snap / cb.CRON_JOBS_FILENAME).exists()
|
||||
|
||||
mf = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert mf["cron_jobs"]["backed_up"] is False
|
||||
assert "cron/jobs.json" in mf["cron_jobs"]["reason"]
|
||||
|
||||
|
||||
def test_snapshot_cron_jobs_malformed_json_still_captured(backup_env):
|
||||
"""Malformed jobs.json is still copied to the snapshot (fidelity over
|
||||
validation); the manifest notes the parse warning."""
|
||||
cb = backup_env["cb"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
(backup_env["home"] / "cron").mkdir()
|
||||
(backup_env["home"] / "cron" / "jobs.json").write_text("{oh no", encoding="utf-8")
|
||||
|
||||
snap = cb.snapshot_skills(reason="test")
|
||||
assert snap is not None
|
||||
# Raw file was copied even though we couldn't parse it
|
||||
assert (snap / cb.CRON_JOBS_FILENAME).read_text() == "{oh no"
|
||||
|
||||
mf = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert mf["cron_jobs"]["backed_up"] is True
|
||||
assert mf["cron_jobs"]["jobs_count"] == 0
|
||||
assert "parse_warning" in mf["cron_jobs"]
|
||||
|
||||
|
||||
def test_rollback_restores_cron_skill_links(backup_env):
|
||||
"""End-to-end: snapshot with job [alpha,beta], curator-style in-place
|
||||
rewrite to [umbrella], then rollback → skills restored to [alpha,beta]."""
|
||||
cb = backup_env["cb"]
|
||||
home = backup_env["home"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
_write_skill(backup_env["skills"], "beta")
|
||||
_write_skill(backup_env["skills"], "umbrella")
|
||||
|
||||
cj = _reload_cron_jobs(home)
|
||||
cj.create_job(name="weekly", prompt="p", schedule="every 7d",
|
||||
skills=["alpha", "beta"])
|
||||
|
||||
snap = cb.snapshot_skills(reason="pre-curator-run")
|
||||
assert snap is not None
|
||||
|
||||
# Simulate the curator's in-place cron rewrite after consolidation
|
||||
cj.rewrite_skill_refs(
|
||||
consolidated={"alpha": "umbrella", "beta": "umbrella"},
|
||||
pruned=[],
|
||||
)
|
||||
live_after_curator = cj.load_jobs()
|
||||
assert live_after_curator[0]["skills"] == ["umbrella"]
|
||||
|
||||
# Now roll back
|
||||
ok, msg, _ = cb.rollback(backup_id=snap.name)
|
||||
assert ok, msg
|
||||
assert "cron links" in msg
|
||||
|
||||
live_after_rollback = cj.load_jobs()
|
||||
# skills restored; legacy `skill` mirror follows first element
|
||||
assert live_after_rollback[0]["skills"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_rollback_only_touches_skill_fields(backup_env):
|
||||
"""Every field other than skills/skill must remain untouched across rollback.
|
||||
Schedule, enabled, prompt, timestamps — all live state, hands off."""
|
||||
cb = backup_env["cb"]
|
||||
home = backup_env["home"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
|
||||
# Hand-rolled jobs.json with varied fields (no real create_job — we want
|
||||
# exact field control).
|
||||
_write_cron_jobs(home, [{
|
||||
"id": "stable-id",
|
||||
"name": "original-name",
|
||||
"prompt": "original prompt",
|
||||
"schedule": "every 1h",
|
||||
"skills": ["alpha"],
|
||||
"enabled": True,
|
||||
"last_run_at": "2026-04-01T00:00:00Z",
|
||||
}])
|
||||
snap = cb.snapshot_skills(reason="pre-curator-run")
|
||||
assert snap is not None
|
||||
|
||||
# User/scheduler activity AFTER the snapshot: rename the job, change
|
||||
# the schedule, update timestamps, and (curator) rewrite the skills list.
|
||||
cj = _reload_cron_jobs(home)
|
||||
jobs = cj.load_jobs()
|
||||
jobs[0]["name"] = "renamed-since-snapshot"
|
||||
jobs[0]["schedule"] = "every 30m"
|
||||
jobs[0]["last_run_at"] = "2026-05-01T12:00:00Z"
|
||||
jobs[0]["skills"] = ["umbrella"] # pretend curator did this
|
||||
cj.save_jobs(jobs)
|
||||
|
||||
ok, _, _ = cb.rollback(backup_id=snap.name)
|
||||
assert ok
|
||||
|
||||
after = cj.load_jobs()
|
||||
job = after[0]
|
||||
# skills: restored
|
||||
assert job["skills"] == ["alpha"]
|
||||
# everything else: untouched (live state preserved)
|
||||
assert job["name"] == "renamed-since-snapshot"
|
||||
assert job["schedule"] == "every 30m"
|
||||
assert job["last_run_at"] == "2026-05-01T12:00:00Z"
|
||||
assert job["prompt"] == "original prompt"
|
||||
|
||||
|
||||
def test_rollback_skips_jobs_the_user_deleted(backup_env):
|
||||
"""If the user deleted a cron job after the snapshot, rollback must
|
||||
NOT resurrect it — the user's delete is a later, explicit choice."""
|
||||
cb = backup_env["cb"]
|
||||
home = backup_env["home"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
|
||||
_write_cron_jobs(home, [
|
||||
{"id": "keep-me", "name": "keep", "schedule": "every 1h", "skills": ["alpha"]},
|
||||
{"id": "delete-me", "name": "gone", "schedule": "every 1h", "skills": ["alpha"]},
|
||||
])
|
||||
snap = cb.snapshot_skills(reason="pre-curator-run")
|
||||
|
||||
# User deletes one job after the snapshot
|
||||
cj = _reload_cron_jobs(home)
|
||||
cj.save_jobs([j for j in cj.load_jobs() if j["id"] != "delete-me"])
|
||||
|
||||
ok, _, _ = cb.rollback(backup_id=snap.name)
|
||||
assert ok
|
||||
|
||||
live_after = cj.load_jobs()
|
||||
live_ids = {j["id"] for j in live_after}
|
||||
assert "keep-me" in live_ids
|
||||
assert "delete-me" not in live_ids # not resurrected
|
||||
|
||||
|
||||
def test_rollback_leaves_new_jobs_untouched(backup_env):
|
||||
"""Jobs created AFTER the snapshot must pass through rollback unchanged."""
|
||||
cb = backup_env["cb"]
|
||||
home = backup_env["home"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
_write_cron_jobs(home, [
|
||||
{"id": "original", "name": "o", "schedule": "every 1h", "skills": ["alpha"]},
|
||||
])
|
||||
snap = cb.snapshot_skills(reason="pre-curator-run")
|
||||
|
||||
cj = _reload_cron_jobs(home)
|
||||
jobs = cj.load_jobs()
|
||||
jobs.append({"id": "new-after-snapshot", "name": "new",
|
||||
"schedule": "every 15m", "skills": ["brand-new-skill"]})
|
||||
cj.save_jobs(jobs)
|
||||
|
||||
ok, _, _ = cb.rollback(backup_id=snap.name)
|
||||
assert ok
|
||||
|
||||
live = cj.load_jobs()
|
||||
by_id = {j["id"]: j for j in live}
|
||||
assert "new-after-snapshot" in by_id
|
||||
# New job's fields completely preserved
|
||||
assert by_id["new-after-snapshot"]["skills"] == ["brand-new-skill"]
|
||||
assert by_id["new-after-snapshot"]["schedule"] == "every 15m"
|
||||
|
||||
|
||||
def test_rollback_with_snapshot_missing_cron_succeeds(backup_env):
|
||||
"""Older snapshots (created before this feature shipped) have no
|
||||
cron-jobs.json. Rollback must still restore the skills tree and not
|
||||
error out."""
|
||||
cb = backup_env["cb"]
|
||||
home = backup_env["home"]
|
||||
_write_skill(backup_env["skills"], "alpha")
|
||||
|
||||
# No cron/jobs.json at snapshot time — simulates a pre-feature snapshot
|
||||
snap = cb.snapshot_skills(reason="test")
|
||||
assert snap is not None
|
||||
assert not (snap / cb.CRON_JOBS_FILENAME).exists()
|
||||
|
||||
# Later the user created a cron job
|
||||
_write_cron_jobs(home, [
|
||||
{"id": "later-job", "name": "l", "schedule": "every 1h", "skills": ["x"]},
|
||||
])
|
||||
|
||||
ok, msg, _ = cb.rollback(backup_id=snap.name)
|
||||
# Main rollback still succeeds; cron report notes the missing file.
|
||||
assert ok, msg
|
||||
# Jobs.json untouched (nothing to restore from)
|
||||
cj = _reload_cron_jobs(home)
|
||||
jobs = cj.load_jobs()
|
||||
assert jobs[0]["id"] == "later-job"
|
||||
assert jobs[0]["skills"] == ["x"]
|
||||
|
||||
|
||||
def test_restore_cron_skill_links_standalone(backup_env):
|
||||
"""Unit-level test on _restore_cron_skill_links without the full rollback.
|
||||
Verifies the report structure carefully."""
|
||||
cb = backup_env["cb"]
|
||||
home = backup_env["home"]
|
||||
|
||||
# Prime a snapshot dir manually with cron-jobs.json
|
||||
backups_dir = home / "skills" / ".curator_backups" / "fake-id"
|
||||
backups_dir.mkdir(parents=True)
|
||||
(backups_dir / cb.CRON_JOBS_FILENAME).write_text(json.dumps([
|
||||
{"id": "job-1", "name": "one", "skills": ["narrow-a", "narrow-b"]},
|
||||
{"id": "job-2", "name": "two", "skill": "legacy-single"},
|
||||
{"id": "job-gone", "name": "deleted", "skills": ["whatever"]},
|
||||
]), encoding="utf-8")
|
||||
|
||||
# Live jobs: job-1 got rewritten, job-2 unchanged, job-gone deleted
|
||||
_write_cron_jobs(home, [
|
||||
{"id": "job-1", "name": "one", "skills": ["umbrella"], "schedule": "every 1h"},
|
||||
{"id": "job-2", "name": "two", "skill": "legacy-single", "schedule": "every 1h"},
|
||||
{"id": "job-new", "name": "new", "skills": ["x"], "schedule": "every 1h"},
|
||||
])
|
||||
_reload_cron_jobs(home)
|
||||
|
||||
report = cb._restore_cron_skill_links(backups_dir)
|
||||
assert report["attempted"] is True
|
||||
assert report["error"] is None
|
||||
assert report["unchanged"] == 1 # job-2 matched
|
||||
assert len(report["restored"]) == 1 # job-1 got restored
|
||||
assert report["restored"][0]["job_id"] == "job-1"
|
||||
assert report["restored"][0]["to"]["skills"] == ["narrow-a", "narrow-b"]
|
||||
assert len(report["skipped_missing"]) == 1
|
||||
assert report["skipped_missing"][0]["job_id"] == "job-gone"
|
||||
|
||||
@@ -548,3 +548,266 @@ def test_reconcile_model_block_visible_in_full_report(curator_env):
|
||||
md = (run_dir / "REPORT.md").read_text()
|
||||
assert "duplicate content, now a subsection" in md
|
||||
assert "pre-curator junk" in md
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_absorbed_into_declarations — authoritative signal from delete calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_absorbed_into_picks_up_consolidation(curator_env):
|
||||
"""Delete call with absorbed_into=<umbrella> yields a declaration."""
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "narrow-skill",
|
||||
"absorbed_into": "umbrella",
|
||||
}),
|
||||
},
|
||||
])
|
||||
assert declarations == {
|
||||
"narrow-skill": {"into": "umbrella", "declared": True},
|
||||
}
|
||||
|
||||
|
||||
def test_extract_absorbed_into_empty_string_is_explicit_prune(curator_env):
|
||||
"""absorbed_into='' is recorded as an explicit prune declaration."""
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "stale",
|
||||
"absorbed_into": "",
|
||||
}),
|
||||
},
|
||||
])
|
||||
assert declarations == {"stale": {"into": "", "declared": True}}
|
||||
|
||||
|
||||
def test_extract_absorbed_into_missing_arg_ignored(curator_env):
|
||||
"""Delete call without absorbed_into is skipped — fallback to heuristic."""
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": "legacy-skill",
|
||||
}),
|
||||
},
|
||||
])
|
||||
assert declarations == {}
|
||||
|
||||
|
||||
def test_extract_absorbed_into_ignores_non_delete_actions(curator_env):
|
||||
"""Patch, create, write_file etc. must not leak into declarations."""
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "patch",
|
||||
"name": "umbrella",
|
||||
"old_string": "...",
|
||||
"new_string": "...",
|
||||
"absorbed_into": "something", # bogus on non-delete, must be ignored
|
||||
}),
|
||||
},
|
||||
])
|
||||
assert declarations == {}
|
||||
|
||||
|
||||
def test_extract_absorbed_into_accepts_dict_arguments(curator_env):
|
||||
"""arguments can arrive as a dict (defensive path) — still works."""
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": {
|
||||
"action": "delete",
|
||||
"name": "narrow",
|
||||
"absorbed_into": "umbrella",
|
||||
},
|
||||
},
|
||||
])
|
||||
assert declarations == {"narrow": {"into": "umbrella", "declared": True}}
|
||||
|
||||
|
||||
def test_extract_absorbed_into_strips_whitespace(curator_env):
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{
|
||||
"name": "skill_manage",
|
||||
"arguments": json.dumps({
|
||||
"action": "delete",
|
||||
"name": " narrow ",
|
||||
"absorbed_into": " umbrella ",
|
||||
}),
|
||||
},
|
||||
])
|
||||
assert declarations == {"narrow": {"into": "umbrella", "declared": True}}
|
||||
|
||||
|
||||
def test_extract_absorbed_into_ignores_non_skill_manage_calls(curator_env):
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{"name": "terminal", "arguments": json.dumps({"command": "ls"})},
|
||||
{"name": "read_file", "arguments": json.dumps({"path": "/tmp/x"})},
|
||||
])
|
||||
assert declarations == {}
|
||||
|
||||
|
||||
def test_extract_absorbed_into_handles_malformed_arguments(curator_env):
|
||||
"""Garbage JSON in arguments must not crash the extractor."""
|
||||
declarations = curator_env._extract_absorbed_into_declarations([
|
||||
{"name": "skill_manage", "arguments": "{not json"},
|
||||
{"name": "skill_manage", "arguments": None},
|
||||
{"name": "skill_manage"}, # no arguments key at all
|
||||
])
|
||||
assert declarations == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _reconcile_classification with absorbed_into declarations (authoritative)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reconcile_absorbed_into_beats_everything_else(curator_env):
|
||||
"""Model declared absorbed_into at delete; YAML/heuristic disagree — declaration wins.
|
||||
|
||||
This is the exact #18671 regression: the model forgets to emit the YAML
|
||||
summary block, the heuristic's substring match misses because the
|
||||
umbrella's patch content doesn't literally contain the old skill's
|
||||
slug. Previously this fell through to 'no-evidence fallback' prune,
|
||||
which dropped the cron ref instead of rewriting. With absorbed_into
|
||||
declared, the model tells us directly.
|
||||
"""
|
||||
out = curator_env._reconcile_classification(
|
||||
removed=["pr-review-format"],
|
||||
heuristic={"consolidated": [], "pruned": [{"name": "pr-review-format"}]},
|
||||
model_block={"consolidations": [], "prunings": []}, # model forgot YAML block
|
||||
destinations={"hermes-agent-dev"},
|
||||
absorbed_declarations={
|
||||
"pr-review-format": {"into": "hermes-agent-dev", "declared": True},
|
||||
},
|
||||
)
|
||||
assert len(out["consolidated"]) == 1
|
||||
assert out["pruned"] == []
|
||||
e = out["consolidated"][0]
|
||||
assert e["name"] == "pr-review-format"
|
||||
assert e["into"] == "hermes-agent-dev"
|
||||
assert "absorbed_into" in e["source"]
|
||||
|
||||
|
||||
def test_reconcile_absorbed_into_empty_is_explicit_prune(curator_env):
|
||||
"""absorbed_into='' takes precedence and routes to pruned, not fallback."""
|
||||
out = curator_env._reconcile_classification(
|
||||
removed=["stale"],
|
||||
heuristic={"consolidated": [], "pruned": [{"name": "stale"}]},
|
||||
model_block={"consolidations": [], "prunings": []},
|
||||
destinations=set(),
|
||||
absorbed_declarations={
|
||||
"stale": {"into": "", "declared": True},
|
||||
},
|
||||
)
|
||||
assert out["consolidated"] == []
|
||||
assert len(out["pruned"]) == 1
|
||||
assert "model-declared prune" in out["pruned"][0]["source"]
|
||||
|
||||
|
||||
def test_reconcile_absorbed_into_nonexistent_target_falls_through(curator_env):
|
||||
"""If the declared umbrella doesn't exist in destinations, fall through to
|
||||
heuristic/YAML logic. Shouldn't happen in practice (the tool validates at
|
||||
delete time) but the reconciler is defensive."""
|
||||
out = curator_env._reconcile_classification(
|
||||
removed=["thing"],
|
||||
heuristic={
|
||||
"consolidated": [{"name": "thing", "into": "real-umbrella", "evidence": "..."}],
|
||||
"pruned": [],
|
||||
},
|
||||
model_block={"consolidations": [], "prunings": []},
|
||||
destinations={"real-umbrella"},
|
||||
absorbed_declarations={
|
||||
"thing": {"into": "ghost-umbrella", "declared": True},
|
||||
},
|
||||
)
|
||||
assert len(out["consolidated"]) == 1
|
||||
assert out["consolidated"][0]["into"] == "real-umbrella"
|
||||
assert "tool-call audit" in out["consolidated"][0]["source"]
|
||||
|
||||
|
||||
def test_reconcile_declaration_preserves_yaml_reason(curator_env):
|
||||
"""When the model both declared absorbed_into AND emitted YAML with reason,
|
||||
the reason carries through so REPORT.md still has it."""
|
||||
out = curator_env._reconcile_classification(
|
||||
removed=["narrow"],
|
||||
heuristic={"consolidated": [], "pruned": []},
|
||||
model_block={
|
||||
"consolidations": [{
|
||||
"from": "narrow",
|
||||
"into": "umbrella",
|
||||
"reason": "duplicate of umbrella's main content",
|
||||
}],
|
||||
"prunings": [],
|
||||
},
|
||||
destinations={"umbrella"},
|
||||
absorbed_declarations={
|
||||
"narrow": {"into": "umbrella", "declared": True},
|
||||
},
|
||||
)
|
||||
assert len(out["consolidated"]) == 1
|
||||
e = out["consolidated"][0]
|
||||
assert e["into"] == "umbrella"
|
||||
assert "absorbed_into" in e["source"]
|
||||
assert e["reason"] == "duplicate of umbrella's main content"
|
||||
|
||||
|
||||
def test_reconcile_without_declarations_preserves_legacy_behavior(curator_env):
|
||||
"""Backward compat: no absorbed_declarations arg → all existing logic intact."""
|
||||
out = curator_env._reconcile_classification(
|
||||
removed=["thing"],
|
||||
heuristic={
|
||||
"consolidated": [{"name": "thing", "into": "umbrella", "evidence": "..."}],
|
||||
"pruned": [],
|
||||
},
|
||||
model_block={"consolidations": [], "prunings": []},
|
||||
destinations={"umbrella"},
|
||||
# no absorbed_declarations — defaults to None → behaves identically to pre-change
|
||||
)
|
||||
assert len(out["consolidated"]) == 1
|
||||
assert out["consolidated"][0]["into"] == "umbrella"
|
||||
|
||||
|
||||
def test_reconcile_mixed_declarations_and_legacy_calls(curator_env):
|
||||
"""Real-world run: some deletes declared absorbed_into, some didn't.
|
||||
Declared ones use the authoritative path; others fall through to YAML/heuristic.
|
||||
"""
|
||||
out = curator_env._reconcile_classification(
|
||||
removed=["declared-cons", "declared-prune", "legacy-cons", "legacy-prune"],
|
||||
heuristic={
|
||||
"consolidated": [
|
||||
{"name": "legacy-cons", "into": "umbrella-a", "evidence": "..."},
|
||||
],
|
||||
"pruned": [{"name": "legacy-prune"}],
|
||||
},
|
||||
model_block={"consolidations": [], "prunings": []},
|
||||
destinations={"umbrella-a", "umbrella-b"},
|
||||
absorbed_declarations={
|
||||
"declared-cons": {"into": "umbrella-b", "declared": True},
|
||||
"declared-prune": {"into": "", "declared": True},
|
||||
},
|
||||
)
|
||||
cons_by_name = {e["name"]: e for e in out["consolidated"]}
|
||||
pruned_by_name = {e["name"]: e for e in out["pruned"]}
|
||||
|
||||
assert "declared-cons" in cons_by_name
|
||||
assert cons_by_name["declared-cons"]["into"] == "umbrella-b"
|
||||
assert "absorbed_into" in cons_by_name["declared-cons"]["source"]
|
||||
|
||||
assert "legacy-cons" in cons_by_name
|
||||
assert cons_by_name["legacy-cons"]["into"] == "umbrella-a"
|
||||
assert "tool-call audit" in cons_by_name["legacy-cons"]["source"]
|
||||
|
||||
assert "declared-prune" in pruned_by_name
|
||||
assert "model-declared prune" in pruned_by_name["declared-prune"]["source"]
|
||||
|
||||
assert "legacy-prune" in pruned_by_name
|
||||
assert "no-evidence fallback" in pruned_by_name["legacy-prune"]["source"]
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Tests for OpenRouter response caching header injection."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_or_headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildOrHeaders:
|
||||
"""Test the build_or_headers() helper in agent/auxiliary_client.py."""
|
||||
|
||||
def test_base_attribution_always_present(self):
|
||||
"""Attribution headers must always be included regardless of cache setting."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": False})
|
||||
assert headers["HTTP-Referer"] == "https://hermes-agent.nousresearch.com"
|
||||
assert headers["X-OpenRouter-Title"] == "Hermes Agent"
|
||||
assert headers["X-OpenRouter-Categories"] == "productivity,cli-agent"
|
||||
|
||||
def test_cache_enabled(self):
|
||||
"""When response_cache is True, X-OpenRouter-Cache header is set."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True})
|
||||
assert headers["X-OpenRouter-Cache"] == "true"
|
||||
|
||||
def test_cache_disabled(self):
|
||||
"""When response_cache is False, no cache header is sent."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": False})
|
||||
assert "X-OpenRouter-Cache" not in headers
|
||||
assert "X-OpenRouter-Cache-TTL" not in headers
|
||||
|
||||
def test_cache_disabled_by_default_empty_config(self):
|
||||
"""Empty config dict means no cache headers (response_cache defaults to False)."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={})
|
||||
assert "X-OpenRouter-Cache" not in headers
|
||||
|
||||
def test_ttl_default(self):
|
||||
"""Default TTL (300) is included when cache is enabled."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 300})
|
||||
assert headers["X-OpenRouter-Cache-TTL"] == "300"
|
||||
|
||||
def test_ttl_custom(self):
|
||||
"""Custom TTL values within range are sent."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 3600})
|
||||
assert headers["X-OpenRouter-Cache-TTL"] == "3600"
|
||||
|
||||
def test_ttl_max(self):
|
||||
"""Maximum TTL (86400) is accepted."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 86400})
|
||||
assert headers["X-OpenRouter-Cache-TTL"] == "86400"
|
||||
|
||||
def test_ttl_out_of_range_too_high(self):
|
||||
"""TTL above 86400 is silently ignored (no TTL header sent)."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 100000})
|
||||
assert "X-OpenRouter-Cache-TTL" not in headers
|
||||
# But cache is still enabled
|
||||
assert headers["X-OpenRouter-Cache"] == "true"
|
||||
|
||||
def test_ttl_out_of_range_zero(self):
|
||||
"""TTL of 0 is below minimum — no TTL header sent."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 0})
|
||||
assert "X-OpenRouter-Cache-TTL" not in headers
|
||||
|
||||
def test_ttl_negative(self):
|
||||
"""Negative TTL is ignored."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": -5})
|
||||
assert "X-OpenRouter-Cache-TTL" not in headers
|
||||
|
||||
def test_ttl_not_a_number(self):
|
||||
"""Non-numeric TTL is ignored."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": "five"})
|
||||
assert "X-OpenRouter-Cache-TTL" not in headers
|
||||
|
||||
def test_ttl_float_truncated(self):
|
||||
"""Float TTL values are truncated to int."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 600.7})
|
||||
assert headers["X-OpenRouter-Cache-TTL"] == "600"
|
||||
|
||||
def test_returns_fresh_dict(self):
|
||||
"""Each call returns a new dict so mutations don't leak."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
cfg = {"response_cache": True}
|
||||
h1 = build_or_headers(or_config=cfg)
|
||||
h2 = build_or_headers(or_config=cfg)
|
||||
assert h1 is not h2
|
||||
assert h1 == h2
|
||||
|
||||
def test_none_config_falls_back_to_load_config(self):
|
||||
"""When or_config is None, build_or_headers reads from load_config()."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
fake_cfg = {
|
||||
"openrouter": {"response_cache": True, "response_cache_ttl": 900},
|
||||
}
|
||||
with patch("hermes_cli.config.load_config", return_value=fake_cfg):
|
||||
headers = build_or_headers(or_config=None)
|
||||
assert headers["X-OpenRouter-Cache"] == "true"
|
||||
assert headers["X-OpenRouter-Cache-TTL"] == "900"
|
||||
|
||||
def test_none_config_load_config_fails_gracefully(self):
|
||||
"""When load_config() fails, build_or_headers still returns base headers."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
|
||||
headers = build_or_headers(or_config=None)
|
||||
# Should have base attribution but no cache headers
|
||||
assert "HTTP-Referer" in headers
|
||||
assert "X-OpenRouter-Cache" not in headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment variable overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnvVarOverrides:
|
||||
"""Test env var precedence over config.yaml for response caching."""
|
||||
|
||||
def test_env_enables_cache(self, monkeypatch):
|
||||
"""HERMES_OPENROUTER_CACHE=true enables cache even when config disables it."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true")
|
||||
headers = build_or_headers(or_config={"response_cache": False})
|
||||
assert headers["X-OpenRouter-Cache"] == "true"
|
||||
|
||||
def test_env_disables_cache(self, monkeypatch):
|
||||
"""HERMES_OPENROUTER_CACHE=false disables cache even when config enables it."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "false")
|
||||
headers = build_or_headers(or_config={"response_cache": True})
|
||||
assert "X-OpenRouter-Cache" not in headers
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "Yes", "on"])
|
||||
def test_truthy_values(self, monkeypatch, value):
|
||||
"""Various truthy strings enable caching."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value)
|
||||
headers = build_or_headers(or_config={})
|
||||
assert headers["X-OpenRouter-Cache"] == "true"
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "no", "off", "maybe", ""])
|
||||
def test_non_truthy_values(self, monkeypatch, value):
|
||||
"""Non-truthy strings do not enable caching (empty falls through to config)."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", value)
|
||||
# Empty string falls through to config; others are explicitly non-truthy
|
||||
if value == "":
|
||||
# Empty env var falls through to config default (False)
|
||||
headers = build_or_headers(or_config={"response_cache": False})
|
||||
else:
|
||||
headers = build_or_headers(or_config={"response_cache": True})
|
||||
assert "X-OpenRouter-Cache" not in headers
|
||||
|
||||
def test_env_ttl_overrides_config(self, monkeypatch):
|
||||
"""HERMES_OPENROUTER_CACHE_TTL overrides config TTL."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "true")
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", "1800")
|
||||
headers = build_or_headers(or_config={"response_cache_ttl": 300})
|
||||
assert headers["X-OpenRouter-Cache-TTL"] == "1800"
|
||||
|
||||
@pytest.mark.parametrize("ttl", ["0", "86401", "abc", "-1", "12.5"])
|
||||
def test_invalid_env_ttl_dropped(self, monkeypatch, ttl):
|
||||
"""Invalid TTL env values are ignored; cache still enabled without TTL."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "1")
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl)
|
||||
headers = build_or_headers(or_config={})
|
||||
assert headers["X-OpenRouter-Cache"] == "true"
|
||||
assert "X-OpenRouter-Cache-TTL" not in headers
|
||||
|
||||
@pytest.mark.parametrize("ttl", ["1", "300", "86400"])
|
||||
def test_valid_env_ttl_boundaries(self, monkeypatch, ttl):
|
||||
"""Boundary TTL values (1, 300, 86400) are accepted."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE", "yes")
|
||||
monkeypatch.setenv("HERMES_OPENROUTER_CACHE_TTL", ttl)
|
||||
assert build_or_headers(or_config={})["X-OpenRouter-Cache-TTL"] == ttl
|
||||
|
||||
def test_no_env_vars_falls_through_to_config(self, monkeypatch):
|
||||
"""Without env vars, config.yaml controls behavior."""
|
||||
from agent.auxiliary_client import build_or_headers
|
||||
|
||||
monkeypatch.delenv("HERMES_OPENROUTER_CACHE", raising=False)
|
||||
monkeypatch.delenv("HERMES_OPENROUTER_CACHE_TTL", raising=False)
|
||||
headers = build_or_headers(or_config={"response_cache": True, "response_cache_ttl": 600})
|
||||
assert headers["X-OpenRouter-Cache"] == "true"
|
||||
assert headers["X-OpenRouter-Cache-TTL"] == "600"
|
||||
|
||||
class TestDefaultConfig:
|
||||
"""Verify the openrouter config section is in DEFAULT_CONFIG."""
|
||||
|
||||
def test_openrouter_section_exists(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
assert "openrouter" in DEFAULT_CONFIG
|
||||
or_cfg = DEFAULT_CONFIG["openrouter"]
|
||||
assert or_cfg["response_cache"] is True
|
||||
assert or_cfg["response_cache_ttl"] == 300
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_openrouter_cache_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckOpenrouterCacheStatus:
|
||||
"""Test the _check_openrouter_cache_status method on AIAgent."""
|
||||
|
||||
def _make_agent(self):
|
||||
"""Create a minimal AIAgent-like object with just the method under test."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
# Use object.__new__ to skip __init__, then set the attributes we need
|
||||
agent = object.__new__(AIAgent)
|
||||
agent._or_cache_hits = 0
|
||||
return agent
|
||||
|
||||
def test_hit_increments_counter(self):
|
||||
agent = self._make_agent()
|
||||
resp = SimpleNamespace(headers={"x-openrouter-cache-status": "HIT"})
|
||||
agent._check_openrouter_cache_status(resp)
|
||||
assert agent._or_cache_hits == 1
|
||||
# Second hit increments
|
||||
agent._check_openrouter_cache_status(resp)
|
||||
assert agent._or_cache_hits == 2
|
||||
|
||||
def test_miss_does_not_increment(self):
|
||||
agent = self._make_agent()
|
||||
resp = SimpleNamespace(headers={"x-openrouter-cache-status": "MISS"})
|
||||
agent._check_openrouter_cache_status(resp)
|
||||
assert getattr(agent, "_or_cache_hits", 0) == 0
|
||||
|
||||
def test_no_header_is_noop(self):
|
||||
agent = self._make_agent()
|
||||
resp = SimpleNamespace(headers={})
|
||||
agent._check_openrouter_cache_status(resp)
|
||||
assert getattr(agent, "_or_cache_hits", 0) == 0
|
||||
|
||||
def test_none_response_is_safe(self):
|
||||
agent = self._make_agent()
|
||||
agent._check_openrouter_cache_status(None) # no crash
|
||||
|
||||
def test_no_headers_attr_is_safe(self):
|
||||
agent = self._make_agent()
|
||||
agent._check_openrouter_cache_status(object()) # no crash
|
||||
|
||||
def test_case_insensitive(self):
|
||||
agent = self._make_agent()
|
||||
resp = SimpleNamespace(headers={"x-openrouter-cache-status": "hit"})
|
||||
agent._check_openrouter_cache_status(resp)
|
||||
assert agent._or_cache_hits == 1
|
||||
@@ -125,6 +125,58 @@ class TestScanSkillCommands:
|
||||
assert "/knowledge-brain" in result
|
||||
assert result["/knowledge-brain"]["name"] == "knowledge-brain"
|
||||
|
||||
def test_get_skill_commands_rescans_when_platform_scope_changes(self, tmp_path):
|
||||
"""Platform-specific disabled-skill caches must not leak across platforms.
|
||||
|
||||
Regression test for #14536: a gateway process serving Telegram
|
||||
and Discord concurrently would seed the process-global cache
|
||||
with whichever platform scanned first, and subsequent
|
||||
``get_skill_commands()`` calls from the other platform silently
|
||||
inherited that filter.
|
||||
"""
|
||||
import agent.skill_commands as sc_mod
|
||||
from agent.skill_commands import get_skill_commands
|
||||
|
||||
def _disabled_skills():
|
||||
platform = os.getenv("HERMES_PLATFORM")
|
||||
if platform == "telegram":
|
||||
return {"telegram-only"}
|
||||
if platform == "discord":
|
||||
return {"discord-only"}
|
||||
return set()
|
||||
|
||||
with (
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
||||
patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills),
|
||||
patch.object(sc_mod, "_skill_commands", {}),
|
||||
patch.object(sc_mod, "_skill_commands_platform", None),
|
||||
):
|
||||
_make_skill(tmp_path, "shared")
|
||||
_make_skill(tmp_path, "telegram-only")
|
||||
_make_skill(tmp_path, "discord-only")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}):
|
||||
telegram_commands = dict(get_skill_commands())
|
||||
|
||||
assert "/shared" in telegram_commands
|
||||
assert "/discord-only" in telegram_commands
|
||||
assert "/telegram-only" not in telegram_commands
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_PLATFORM": "discord"}):
|
||||
discord_commands = dict(get_skill_commands())
|
||||
|
||||
assert "/shared" in discord_commands
|
||||
assert "/telegram-only" in discord_commands
|
||||
assert "/discord-only" not in discord_commands
|
||||
|
||||
# Switching back to telegram must also rescan — not re-serve
|
||||
# the discord view that was just cached.
|
||||
with patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}):
|
||||
telegram_again = dict(get_skill_commands())
|
||||
|
||||
assert "/telegram-only" not in telegram_again
|
||||
assert "/discord-only" in telegram_again
|
||||
|
||||
|
||||
def test_special_chars_stripped_from_cmd_key(self, tmp_path):
|
||||
"""Skill names with +, /, or other special chars produce clean cmd keys."""
|
||||
|
||||
Reference in New Issue
Block a user