Files
hermes-agent/tests/cron/test_cron_failure_notice_copy.py

90 lines
4.0 KiB
Python

"""User-facing cron failure notices: plain words, the real output path, and the exact `hermes cron`
command to act on. Contract tests, not snapshots (root AGENTS.md).
The classifier is `agent.error_classifier.classify_api_error`; these tests pin what the copy table
does with its verdict, not the verdict itself.
"""
import re
import cron.scheduler as scheduler
from cron.scheduler import _compose_run_delivery, _summarize_cron_failure_for_delivery
from hermes_constants import display_hermes_home
JOB = {"name": "Morning brief", "id": "ab12cd34"}
_HTTP_LEAD = re.compile(r"failed: (HTTP|Error code:|provider )")
def _no_chain(monkeypatch):
monkeypatch.setattr(scheduler, "load_config", lambda: {})
monkeypatch.setattr(scheduler, "get_fallback_chain", lambda cfg: [])
def test_generic_failure_names_runs_and_pause_commands_and_the_real_output_dir():
msg = _summarize_cron_failure_for_delivery(JOB, "[Errno 2] No such file or directory: '/x.py'")
assert "/x.py" in msg # the raw detail survives as the cause
for cmd in ("hermes cron runs ab12cd34", "hermes cron run ab12cd34", "hermes cron pause ab12cd34"):
assert f"`{cmd}`" in msg
assert f"{display_hermes_home()}/cron/output/ab12cd34/" in msg
assert "cron output" not in msg # the unnamed internal location is gone
def test_auth_failure_points_at_login_and_a_retry_command(monkeypatch):
_no_chain(monkeypatch)
msg = _summarize_cron_failure_for_delivery(JOB, "Error code: 401 - Unauthorized")
assert "/login" in msg and "`hermes auth add <provider>`" in msg
assert "hermes login" not in msg # that command was removed
assert "`hermes cron run ab12cd34`" in msg
assert not _HTTP_LEAD.search(msg)
assert "401" not in msg
def test_rate_and_usage_limit_phrases_still_yield_a_provider_notice(monkeypatch):
"""The old cron regex ladder matched these substrings; the shared classifier must too, or a
Nous Portal limit turns into a raw generic notice."""
_no_chain(monkeypatch)
for text in (
"Nous Portal rate limit active until 15:00",
"RuntimeError: usage limit reached for this key",
"You have hit your weekly usage limit",
"insufficient quota",
):
msg = _summarize_cron_failure_for_delivery(JOB, text)
assert "limit" in msg.lower(), msg
assert not _HTTP_LEAD.search(msg), msg
assert "`hermes cron run ab12cd34`" in msg or "`hermes cron edit ab12cd34" in msg, msg
def test_cron_cause_gloss_is_the_shared_table():
"""Cron, subagent and chat notices read one reason->cause table (agent/turn_failure_copy.py)."""
from agent.turn_failure_copy import FAILURE_CAUSE_GLOSS
from cron.scheduler_failure_copy import provider_failure_notice
for reason in FAILURE_CAUSE_GLOSS:
notice = provider_failure_notice("Morning brief", "ab12cd34", reason, backup_provider_phrase="x.")
assert notice is not None and "`hermes cron" in notice, reason
assert provider_failure_notice("Morning brief", "ab12cd34", "unknown", backup_provider_phrase="x.") is None
def test_transient_provider_failures_never_lead_with_jargon(monkeypatch):
_no_chain(monkeypatch)
for err in ("Request timed out.", "HTTP 429: Too Many Requests"):
msg = _summarize_cron_failure_for_delivery(JOB, err)
assert not _HTTP_LEAD.search(msg), msg
assert "fallback chain" not in msg.lower()
assert "`hermes cron run ab12cd34`" in msg
assert "`hermes cron runs ab12cd34`" in msg
def test_blocked_config_notice_says_it_did_not_run_and_will_self_heal():
text, blocked, *_ = _compose_run_delivery(
JOB, success=False, error="[blocked_config] provider credential missing: no key",
final_response="", output_file=None)
assert blocked is True
assert "did not run" in text
assert "provider credential missing: no key" in text
assert "Nothing was charged" in text
assert "`hermes cron doctor`" in text
for jargon in ("configuration validation", "LLM call", "pre-dispatch"):
assert jargon not in text