The architecture has been updated
This commit is contained in:
parent
805f7a017e
commit
a01257ead9
1119 changed files with 226 additions and 352 deletions
0
hermes_code/tests/cron/__init__.py
Normal file
0
hermes_code/tests/cron/__init__.py
Normal file
459
hermes_code/tests/cron/test_jobs.py
Normal file
459
hermes_code/tests/cron/test_jobs.py
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
"""Tests for cron/jobs.py — schedule parsing, job CRUD, and due-job detection."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cron.jobs import (
|
||||
parse_duration,
|
||||
parse_schedule,
|
||||
compute_next_run,
|
||||
create_job,
|
||||
load_jobs,
|
||||
save_jobs,
|
||||
get_job,
|
||||
list_jobs,
|
||||
update_job,
|
||||
pause_job,
|
||||
resume_job,
|
||||
remove_job,
|
||||
mark_job_run,
|
||||
get_due_jobs,
|
||||
save_job_output,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# parse_duration
|
||||
# =========================================================================
|
||||
|
||||
class TestParseDuration:
|
||||
def test_minutes(self):
|
||||
assert parse_duration("30m") == 30
|
||||
assert parse_duration("1min") == 1
|
||||
assert parse_duration("5mins") == 5
|
||||
assert parse_duration("10minute") == 10
|
||||
assert parse_duration("120minutes") == 120
|
||||
|
||||
def test_hours(self):
|
||||
assert parse_duration("2h") == 120
|
||||
assert parse_duration("1hr") == 60
|
||||
assert parse_duration("3hrs") == 180
|
||||
assert parse_duration("1hour") == 60
|
||||
assert parse_duration("24hours") == 1440
|
||||
|
||||
def test_days(self):
|
||||
assert parse_duration("1d") == 1440
|
||||
assert parse_duration("7day") == 7 * 1440
|
||||
assert parse_duration("2days") == 2 * 1440
|
||||
|
||||
def test_whitespace_tolerance(self):
|
||||
assert parse_duration(" 30m ") == 30
|
||||
assert parse_duration("2 h") == 120
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_duration("abc")
|
||||
with pytest.raises(ValueError):
|
||||
parse_duration("30x")
|
||||
with pytest.raises(ValueError):
|
||||
parse_duration("")
|
||||
with pytest.raises(ValueError):
|
||||
parse_duration("m30")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# parse_schedule
|
||||
# =========================================================================
|
||||
|
||||
class TestParseSchedule:
|
||||
def test_duration_becomes_once(self):
|
||||
result = parse_schedule("30m")
|
||||
assert result["kind"] == "once"
|
||||
assert "run_at" in result
|
||||
# run_at should be a valid ISO timestamp string ~30 minutes from now
|
||||
run_at_str = result["run_at"]
|
||||
assert isinstance(run_at_str, str)
|
||||
run_at = datetime.fromisoformat(run_at_str)
|
||||
now = datetime.now().astimezone()
|
||||
assert run_at > now
|
||||
assert run_at < now + timedelta(minutes=31)
|
||||
|
||||
def test_every_becomes_interval(self):
|
||||
result = parse_schedule("every 2h")
|
||||
assert result["kind"] == "interval"
|
||||
assert result["minutes"] == 120
|
||||
|
||||
def test_every_case_insensitive(self):
|
||||
result = parse_schedule("Every 30m")
|
||||
assert result["kind"] == "interval"
|
||||
assert result["minutes"] == 30
|
||||
|
||||
def test_cron_expression(self):
|
||||
pytest.importorskip("croniter")
|
||||
result = parse_schedule("0 9 * * *")
|
||||
assert result["kind"] == "cron"
|
||||
assert result["expr"] == "0 9 * * *"
|
||||
|
||||
def test_iso_timestamp(self):
|
||||
result = parse_schedule("2030-01-15T14:00:00")
|
||||
assert result["kind"] == "once"
|
||||
assert "2030-01-15" in result["run_at"]
|
||||
|
||||
def test_invalid_schedule_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_schedule("not_a_schedule")
|
||||
|
||||
def test_invalid_cron_raises(self):
|
||||
pytest.importorskip("croniter")
|
||||
with pytest.raises(ValueError):
|
||||
parse_schedule("99 99 99 99 99")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# compute_next_run
|
||||
# =========================================================================
|
||||
|
||||
class TestComputeNextRun:
|
||||
def test_once_future_returns_time(self):
|
||||
future = (datetime.now() + timedelta(hours=1)).isoformat()
|
||||
schedule = {"kind": "once", "run_at": future}
|
||||
assert compute_next_run(schedule) == future
|
||||
|
||||
def test_once_recent_past_within_grace_returns_time(self, monkeypatch):
|
||||
now = datetime(2026, 3, 18, 4, 22, 3, tzinfo=timezone.utc)
|
||||
run_at = "2026-03-18T04:22:00+00:00"
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
||||
|
||||
schedule = {"kind": "once", "run_at": run_at}
|
||||
|
||||
assert compute_next_run(schedule) == run_at
|
||||
|
||||
def test_once_past_returns_none(self):
|
||||
past = (datetime.now() - timedelta(hours=1)).isoformat()
|
||||
schedule = {"kind": "once", "run_at": past}
|
||||
assert compute_next_run(schedule) is None
|
||||
|
||||
def test_once_with_last_run_returns_none_even_within_grace(self, monkeypatch):
|
||||
now = datetime(2026, 3, 18, 4, 22, 3, tzinfo=timezone.utc)
|
||||
run_at = "2026-03-18T04:22:00+00:00"
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
||||
|
||||
schedule = {"kind": "once", "run_at": run_at}
|
||||
|
||||
assert compute_next_run(schedule, last_run_at=now.isoformat()) is None
|
||||
|
||||
def test_interval_first_run(self):
|
||||
schedule = {"kind": "interval", "minutes": 60}
|
||||
result = compute_next_run(schedule)
|
||||
next_dt = datetime.fromisoformat(result)
|
||||
# Should be ~60 minutes from now
|
||||
assert next_dt > datetime.now().astimezone() + timedelta(minutes=59)
|
||||
|
||||
def test_interval_subsequent_run(self):
|
||||
schedule = {"kind": "interval", "minutes": 30}
|
||||
last = datetime.now().astimezone().isoformat()
|
||||
result = compute_next_run(schedule, last_run_at=last)
|
||||
next_dt = datetime.fromisoformat(result)
|
||||
# Should be ~30 minutes from last run
|
||||
assert next_dt > datetime.now().astimezone() + timedelta(minutes=29)
|
||||
|
||||
def test_cron_returns_future(self):
|
||||
pytest.importorskip("croniter")
|
||||
schedule = {"kind": "cron", "expr": "* * * * *"} # every minute
|
||||
result = compute_next_run(schedule)
|
||||
assert isinstance(result, str), f"Expected ISO timestamp string, got {type(result)}"
|
||||
assert len(result) > 0
|
||||
next_dt = datetime.fromisoformat(result)
|
||||
assert isinstance(next_dt, datetime)
|
||||
assert next_dt > datetime.now().astimezone()
|
||||
|
||||
def test_unknown_kind_returns_none(self):
|
||||
assert compute_next_run({"kind": "unknown"}) is None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Job CRUD (with tmp file storage)
|
||||
# =========================================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_cron_dir(tmp_path, monkeypatch):
|
||||
"""Redirect cron storage to a temp directory."""
|
||||
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
|
||||
|
||||
|
||||
class TestJobCRUD:
|
||||
def test_create_and_get(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Check server status", schedule="30m")
|
||||
assert job["id"]
|
||||
assert job["prompt"] == "Check server status"
|
||||
assert job["enabled"] is True
|
||||
assert job["schedule"]["kind"] == "once"
|
||||
|
||||
fetched = get_job(job["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["prompt"] == "Check server status"
|
||||
|
||||
def test_list_jobs(self, tmp_cron_dir):
|
||||
create_job(prompt="Job 1", schedule="every 1h")
|
||||
create_job(prompt="Job 2", schedule="every 2h")
|
||||
jobs = list_jobs()
|
||||
assert len(jobs) == 2
|
||||
|
||||
def test_remove_job(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Temp job", schedule="30m")
|
||||
assert remove_job(job["id"]) is True
|
||||
assert get_job(job["id"]) is None
|
||||
|
||||
def test_remove_nonexistent_returns_false(self, tmp_cron_dir):
|
||||
assert remove_job("nonexistent") is False
|
||||
|
||||
def test_auto_repeat_for_once(self, tmp_cron_dir):
|
||||
job = create_job(prompt="One-shot", schedule="1h")
|
||||
assert job["repeat"]["times"] == 1
|
||||
|
||||
def test_interval_no_auto_repeat(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Recurring", schedule="every 1h")
|
||||
assert job["repeat"]["times"] is None
|
||||
|
||||
def test_default_delivery_origin(self, tmp_cron_dir):
|
||||
job = create_job(
|
||||
prompt="Test", schedule="30m",
|
||||
origin={"platform": "telegram", "chat_id": "123"},
|
||||
)
|
||||
assert job["deliver"] == "origin"
|
||||
|
||||
def test_default_delivery_local_no_origin(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Test", schedule="30m")
|
||||
assert job["deliver"] == "local"
|
||||
|
||||
|
||||
class TestUpdateJob:
|
||||
def test_update_name(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Check server status", schedule="every 1h", name="Old Name")
|
||||
assert job["name"] == "Old Name"
|
||||
updated = update_job(job["id"], {"name": "New Name"})
|
||||
assert updated is not None
|
||||
assert isinstance(updated, dict)
|
||||
assert updated["name"] == "New Name"
|
||||
# Verify other fields are preserved
|
||||
assert updated["prompt"] == "Check server status"
|
||||
assert updated["id"] == job["id"]
|
||||
assert updated["schedule"] == job["schedule"]
|
||||
# Verify persisted to disk
|
||||
fetched = get_job(job["id"])
|
||||
assert fetched["name"] == "New Name"
|
||||
|
||||
def test_update_schedule(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Daily report", schedule="every 1h")
|
||||
assert job["schedule"]["kind"] == "interval"
|
||||
assert job["schedule"]["minutes"] == 60
|
||||
old_next_run = job["next_run_at"]
|
||||
new_schedule = parse_schedule("every 2h")
|
||||
updated = update_job(job["id"], {"schedule": new_schedule, "schedule_display": new_schedule["display"]})
|
||||
assert updated is not None
|
||||
assert updated["schedule"]["kind"] == "interval"
|
||||
assert updated["schedule"]["minutes"] == 120
|
||||
assert updated["schedule_display"] == "every 120m"
|
||||
assert updated["next_run_at"] != old_next_run
|
||||
# Verify persisted to disk
|
||||
fetched = get_job(job["id"])
|
||||
assert fetched["schedule"]["minutes"] == 120
|
||||
assert fetched["schedule_display"] == "every 120m"
|
||||
|
||||
def test_update_enable_disable(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Toggle me", schedule="every 1h")
|
||||
assert job["enabled"] is True
|
||||
updated = update_job(job["id"], {"enabled": False})
|
||||
assert updated["enabled"] is False
|
||||
fetched = get_job(job["id"])
|
||||
assert fetched["enabled"] is False
|
||||
|
||||
def test_update_nonexistent_returns_none(self, tmp_cron_dir):
|
||||
result = update_job("nonexistent_id", {"name": "X"})
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPauseResumeJob:
|
||||
def test_pause_sets_state(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Pause me", schedule="every 1h")
|
||||
paused = pause_job(job["id"], reason="user paused")
|
||||
assert paused is not None
|
||||
assert paused["enabled"] is False
|
||||
assert paused["state"] == "paused"
|
||||
assert paused["paused_reason"] == "user paused"
|
||||
|
||||
def test_resume_reenables_job(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Resume me", schedule="every 1h")
|
||||
pause_job(job["id"], reason="user paused")
|
||||
resumed = resume_job(job["id"])
|
||||
assert resumed is not None
|
||||
assert resumed["enabled"] is True
|
||||
assert resumed["state"] == "scheduled"
|
||||
assert resumed["paused_at"] is None
|
||||
assert resumed["paused_reason"] is None
|
||||
|
||||
|
||||
class TestMarkJobRun:
|
||||
def test_increments_completed(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Test", schedule="every 1h")
|
||||
mark_job_run(job["id"], success=True)
|
||||
updated = get_job(job["id"])
|
||||
assert updated["repeat"]["completed"] == 1
|
||||
assert updated["last_status"] == "ok"
|
||||
|
||||
def test_repeat_limit_removes_job(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Once", schedule="30m", repeat=1)
|
||||
mark_job_run(job["id"], success=True)
|
||||
# Job should be removed after hitting repeat limit
|
||||
assert get_job(job["id"]) is None
|
||||
|
||||
def test_repeat_negative_one_is_infinite(self, tmp_cron_dir):
|
||||
# LLMs often pass repeat=-1 to mean "infinite/forever".
|
||||
# The job must NOT be deleted after runs when repeat <= 0.
|
||||
job = create_job(prompt="Forever", schedule="every 1h", repeat=-1)
|
||||
# -1 should be normalised to None (infinite) at create time
|
||||
assert job["repeat"]["times"] is None
|
||||
# Running it multiple times should never delete it
|
||||
for _ in range(3):
|
||||
mark_job_run(job["id"], success=True)
|
||||
assert get_job(job["id"]) is not None, "job was deleted after run despite infinite repeat"
|
||||
|
||||
def test_repeat_zero_is_infinite(self, tmp_cron_dir):
|
||||
# repeat=0 should also be treated as None (infinite), not "run zero times".
|
||||
job = create_job(prompt="ZeroRepeat", schedule="every 1h", repeat=0)
|
||||
assert job["repeat"]["times"] is None
|
||||
mark_job_run(job["id"], success=True)
|
||||
assert get_job(job["id"]) is not None
|
||||
|
||||
def test_error_status(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Fail", schedule="every 1h")
|
||||
mark_job_run(job["id"], success=False, error="timeout")
|
||||
updated = get_job(job["id"])
|
||||
assert updated["last_status"] == "error"
|
||||
assert updated["last_error"] == "timeout"
|
||||
|
||||
|
||||
class TestGetDueJobs:
|
||||
def test_past_due_within_window_returned(self, tmp_cron_dir):
|
||||
"""Jobs within the dynamic grace window are still considered due (not stale).
|
||||
|
||||
For an hourly job, grace = 30 min (half the period, clamped to [120s, 2h]).
|
||||
"""
|
||||
job = create_job(prompt="Due now", schedule="every 1h")
|
||||
# Force next_run_at to 10 minutes ago (within the 30-min grace for hourly)
|
||||
jobs = load_jobs()
|
||||
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=10)).isoformat()
|
||||
save_jobs(jobs)
|
||||
|
||||
due = get_due_jobs()
|
||||
assert len(due) == 1
|
||||
assert due[0]["id"] == job["id"]
|
||||
|
||||
def test_stale_past_due_skipped(self, tmp_cron_dir):
|
||||
"""Recurring jobs past their dynamic grace window are fast-forwarded, not fired.
|
||||
|
||||
For an hourly job, grace = 30 min. Setting 35 min late exceeds the window.
|
||||
"""
|
||||
job = create_job(prompt="Stale", schedule="every 1h")
|
||||
# Force next_run_at to 35 minutes ago (beyond the 30-min grace for hourly)
|
||||
jobs = load_jobs()
|
||||
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=35)).isoformat()
|
||||
save_jobs(jobs)
|
||||
|
||||
due = get_due_jobs()
|
||||
assert len(due) == 0
|
||||
# next_run_at should be fast-forwarded to the future
|
||||
updated = get_job(job["id"])
|
||||
from cron.jobs import _ensure_aware, _hermes_now
|
||||
next_dt = _ensure_aware(datetime.fromisoformat(updated["next_run_at"]))
|
||||
assert next_dt > _hermes_now()
|
||||
|
||||
def test_future_not_returned(self, tmp_cron_dir):
|
||||
create_job(prompt="Not yet", schedule="every 1h")
|
||||
due = get_due_jobs()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_disabled_not_returned(self, tmp_cron_dir):
|
||||
job = create_job(prompt="Disabled", schedule="every 1h")
|
||||
jobs = load_jobs()
|
||||
jobs[0]["enabled"] = False
|
||||
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=5)).isoformat()
|
||||
save_jobs(jobs)
|
||||
|
||||
due = get_due_jobs()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_broken_recent_one_shot_without_next_run_is_recovered(self, tmp_cron_dir, monkeypatch):
|
||||
now = datetime(2026, 3, 18, 4, 22, 30, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
||||
|
||||
run_at = "2026-03-18T04:22:00+00:00"
|
||||
save_jobs(
|
||||
[{
|
||||
"id": "oneshot-recover",
|
||||
"name": "Recover me",
|
||||
"prompt": "Word of the day",
|
||||
"schedule": {"kind": "once", "run_at": run_at, "display": "once at 2026-03-18 04:22"},
|
||||
"schedule_display": "once at 2026-03-18 04:22",
|
||||
"repeat": {"times": 1, "completed": 0},
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"paused_at": None,
|
||||
"paused_reason": None,
|
||||
"created_at": "2026-03-18T04:21:00+00:00",
|
||||
"next_run_at": None,
|
||||
"last_run_at": None,
|
||||
"last_status": None,
|
||||
"last_error": None,
|
||||
"deliver": "local",
|
||||
"origin": None,
|
||||
}]
|
||||
)
|
||||
|
||||
due = get_due_jobs()
|
||||
|
||||
assert [job["id"] for job in due] == ["oneshot-recover"]
|
||||
assert get_job("oneshot-recover")["next_run_at"] == run_at
|
||||
|
||||
def test_broken_stale_one_shot_without_next_run_is_not_recovered(self, tmp_cron_dir, monkeypatch):
|
||||
now = datetime(2026, 3, 18, 4, 30, 0, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
|
||||
|
||||
save_jobs(
|
||||
[{
|
||||
"id": "oneshot-stale",
|
||||
"name": "Too old",
|
||||
"prompt": "Word of the day",
|
||||
"schedule": {"kind": "once", "run_at": "2026-03-18T04:22:00+00:00", "display": "once at 2026-03-18 04:22"},
|
||||
"schedule_display": "once at 2026-03-18 04:22",
|
||||
"repeat": {"times": 1, "completed": 0},
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"paused_at": None,
|
||||
"paused_reason": None,
|
||||
"created_at": "2026-03-18T04:21:00+00:00",
|
||||
"next_run_at": None,
|
||||
"last_run_at": None,
|
||||
"last_status": None,
|
||||
"last_error": None,
|
||||
"deliver": "local",
|
||||
"origin": None,
|
||||
}]
|
||||
)
|
||||
|
||||
assert get_due_jobs() == []
|
||||
assert get_job("oneshot-stale")["next_run_at"] is None
|
||||
|
||||
|
||||
class TestSaveJobOutput:
|
||||
def test_creates_output_file(self, tmp_cron_dir):
|
||||
output_file = save_job_output("test123", "# Results\nEverything ok.")
|
||||
assert output_file.exists()
|
||||
assert output_file.read_text() == "# Results\nEverything ok."
|
||||
assert "test123" in str(output_file)
|
||||
685
hermes_code/tests/cron/test_scheduler.py
Normal file
685
hermes_code/tests/cron/test_scheduler.py
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
"""Tests for cron/scheduler.py — origin resolution, delivery routing, and error logging."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job, SILENT_MARKER, _build_job_prompt
|
||||
|
||||
|
||||
class TestResolveOrigin:
|
||||
def test_full_origin(self):
|
||||
job = {
|
||||
"origin": {
|
||||
"platform": "telegram",
|
||||
"chat_id": "123456",
|
||||
"chat_name": "Test Chat",
|
||||
"thread_id": "42",
|
||||
}
|
||||
}
|
||||
result = _resolve_origin(job)
|
||||
assert isinstance(result, dict)
|
||||
assert result == job["origin"]
|
||||
assert result["platform"] == "telegram"
|
||||
assert result["chat_id"] == "123456"
|
||||
assert result["chat_name"] == "Test Chat"
|
||||
assert result["thread_id"] == "42"
|
||||
|
||||
def test_no_origin(self):
|
||||
assert _resolve_origin({}) is None
|
||||
assert _resolve_origin({"origin": None}) is None
|
||||
|
||||
def test_missing_platform(self):
|
||||
job = {"origin": {"chat_id": "123"}}
|
||||
assert _resolve_origin(job) is None
|
||||
|
||||
def test_missing_chat_id(self):
|
||||
job = {"origin": {"platform": "telegram"}}
|
||||
assert _resolve_origin(job) is None
|
||||
|
||||
def test_empty_origin(self):
|
||||
job = {"origin": {}}
|
||||
assert _resolve_origin(job) is None
|
||||
|
||||
|
||||
class TestResolveDeliveryTarget:
|
||||
def test_origin_delivery_preserves_thread_id(self):
|
||||
job = {
|
||||
"deliver": "origin",
|
||||
"origin": {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1001",
|
||||
"thread_id": "17585",
|
||||
},
|
||||
}
|
||||
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1001",
|
||||
"thread_id": "17585",
|
||||
}
|
||||
|
||||
def test_explicit_telegram_topic_target_with_thread_id(self):
|
||||
"""deliver: 'telegram:chat_id:thread_id' parses correctly."""
|
||||
job = {
|
||||
"deliver": "telegram:-1003724596514:17",
|
||||
}
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1003724596514",
|
||||
"thread_id": "17",
|
||||
}
|
||||
|
||||
def test_explicit_telegram_chat_id_without_thread_id(self):
|
||||
"""deliver: 'telegram:chat_id' sets thread_id to None."""
|
||||
job = {
|
||||
"deliver": "telegram:-1003724596514",
|
||||
}
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1003724596514",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_bare_platform_uses_matching_origin_chat(self):
|
||||
job = {
|
||||
"deliver": "telegram",
|
||||
"origin": {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1001",
|
||||
"thread_id": "17585",
|
||||
},
|
||||
}
|
||||
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1001",
|
||||
"thread_id": "17585",
|
||||
}
|
||||
|
||||
def test_bare_platform_falls_back_to_home_channel(self, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-2002")
|
||||
job = {
|
||||
"deliver": "telegram",
|
||||
"origin": {
|
||||
"platform": "discord",
|
||||
"chat_id": "abc",
|
||||
},
|
||||
}
|
||||
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-2002",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
|
||||
class TestDeliverResultWrapping:
|
||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||
|
||||
def test_delivery_wraps_content_with_header_and_footer(self):
|
||||
"""Delivered content should include task name header and agent-invisible note."""
|
||||
from gateway.config import Platform
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock:
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"name": "daily-report",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
_deliver_result(job, "Here is today's summary.")
|
||||
|
||||
send_mock.assert_called_once()
|
||||
sent_content = send_mock.call_args.kwargs.get("content") or send_mock.call_args[0][-1]
|
||||
assert "Cronjob Response: daily-report" in sent_content
|
||||
assert "-------------" in sent_content
|
||||
assert "Here is today's summary." in sent_content
|
||||
assert "The agent cannot see this message" in sent_content
|
||||
|
||||
def test_delivery_uses_job_id_when_no_name(self):
|
||||
"""When a job has no name, the wrapper should fall back to job id."""
|
||||
from gateway.config import Platform
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock:
|
||||
job = {
|
||||
"id": "abc-123",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
_deliver_result(job, "Output.")
|
||||
|
||||
sent_content = send_mock.call_args.kwargs.get("content") or send_mock.call_args[0][-1]
|
||||
assert "Cronjob Response: abc-123" in sent_content
|
||||
|
||||
def test_no_mirror_to_session_call(self):
|
||||
"""Cron deliveries should NOT mirror into the gateway session."""
|
||||
from gateway.config import Platform
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})), \
|
||||
patch("gateway.mirror.mirror_to_session") as mirror_mock:
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
_deliver_result(job, "Hello!")
|
||||
|
||||
mirror_mock.assert_not_called()
|
||||
|
||||
def test_origin_delivery_preserves_thread_id(self):
|
||||
"""Origin delivery should forward thread_id to the send helper."""
|
||||
from gateway.config import Platform
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
|
||||
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"name": "topic-job",
|
||||
"deliver": "origin",
|
||||
"origin": {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-1001",
|
||||
"thread_id": "17585",
|
||||
},
|
||||
}
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock:
|
||||
_deliver_result(job, "hello")
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args.kwargs["thread_id"] == "17585"
|
||||
|
||||
|
||||
class TestRunJobSessionPersistence:
|
||||
def test_run_job_passes_session_db_and_cron_platform(self, tmp_path):
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"name": "test",
|
||||
"prompt": "hello",
|
||||
}
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
success, output, final_response, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert "ok" in output
|
||||
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
assert kwargs["session_db"] is fake_db
|
||||
assert kwargs["platform"] == "cron"
|
||||
assert kwargs["session_id"].startswith("cron_test-job_")
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
|
||||
"""Empty final_response should stay empty for delivery logic (issue #2234).
|
||||
|
||||
The placeholder '(No response generated)' should only appear in the
|
||||
output log, not in the returned final_response that's used for delivery.
|
||||
"""
|
||||
job = {
|
||||
"id": "silent-job",
|
||||
"name": "silent test",
|
||||
"prompt": "do work via tools only",
|
||||
}
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
# Agent did work via tools but returned no text
|
||||
mock_agent.run_conversation.return_value = {"final_response": ""}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
success, output, final_response, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
# final_response should be empty for delivery logic to skip
|
||||
assert final_response == ""
|
||||
# But the output log should show the placeholder
|
||||
assert "(No response generated)" in output
|
||||
|
||||
def test_run_job_sets_auto_delivery_env_from_dotenv_home_channel(self, tmp_path, monkeypatch):
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"name": "test",
|
||||
"prompt": "hello",
|
||||
"deliver": "telegram",
|
||||
}
|
||||
fake_db = MagicMock()
|
||||
seen = {}
|
||||
|
||||
(tmp_path / ".env").write_text("TELEGRAM_HOME_CHANNEL=-2002\n")
|
||||
monkeypatch.delenv("TELEGRAM_HOME_CHANNEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_CRON_AUTO_DELIVER_PLATFORM", raising=False)
|
||||
monkeypatch.delenv("HERMES_CRON_AUTO_DELIVER_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_CRON_AUTO_DELIVER_THREAD_ID", raising=False)
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def run_conversation(self, *args, **kwargs):
|
||||
seen["platform"] = os.getenv("HERMES_CRON_AUTO_DELIVER_PLATFORM")
|
||||
seen["chat_id"] = os.getenv("HERMES_CRON_AUTO_DELIVER_CHAT_ID")
|
||||
seen["thread_id"] = os.getenv("HERMES_CRON_AUTO_DELIVER_THREAD_ID")
|
||||
return {"final_response": "ok"}
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "***",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("run_agent.AIAgent", FakeAgent):
|
||||
success, output, final_response, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert "ok" in output
|
||||
assert seen == {
|
||||
"platform": "telegram",
|
||||
"chat_id": "-2002",
|
||||
"thread_id": None,
|
||||
}
|
||||
assert os.getenv("HERMES_CRON_AUTO_DELIVER_PLATFORM") is None
|
||||
assert os.getenv("HERMES_CRON_AUTO_DELIVER_CHAT_ID") is None
|
||||
assert os.getenv("HERMES_CRON_AUTO_DELIVER_THREAD_ID") is None
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
|
||||
class TestRunJobConfigLogging:
|
||||
"""Verify that config.yaml parse failures are logged, not silently swallowed."""
|
||||
|
||||
def test_bad_config_yaml_is_logged(self, caplog, tmp_path):
|
||||
"""When config.yaml is malformed, a warning should be logged."""
|
||||
bad_yaml = tmp_path / "config.yaml"
|
||||
bad_yaml.write_text("invalid: yaml: [[[bad")
|
||||
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"name": "test",
|
||||
"prompt": "hello",
|
||||
}
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="cron.scheduler"):
|
||||
run_job(job)
|
||||
|
||||
assert any("failed to load config.yaml" in r.message for r in caplog.records), \
|
||||
f"Expected 'failed to load config.yaml' warning in logs, got: {[r.message for r in caplog.records]}"
|
||||
|
||||
def test_bad_prefill_messages_is_logged(self, caplog, tmp_path):
|
||||
"""When the prefill messages file contains invalid JSON, a warning should be logged."""
|
||||
# Valid config.yaml that points to a bad prefill file
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text("prefill_messages_file: prefill.json\n")
|
||||
|
||||
bad_prefill = tmp_path / "prefill.json"
|
||||
bad_prefill.write_text("{not valid json!!!")
|
||||
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"name": "test",
|
||||
"prompt": "hello",
|
||||
}
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="cron.scheduler"):
|
||||
run_job(job)
|
||||
|
||||
assert any("failed to parse prefill messages" in r.message for r in caplog.records), \
|
||||
f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"
|
||||
|
||||
|
||||
class TestRunJobPerJobOverrides:
|
||||
def test_job_level_model_provider_and_base_url_overrides_are_used(self, tmp_path):
|
||||
config_yaml = tmp_path / "config.yaml"
|
||||
config_yaml.write_text(
|
||||
"model:\n"
|
||||
" default: gpt-5.4\n"
|
||||
" provider: openai-codex\n"
|
||||
" base_url: https://chatgpt.com/backend-api/codex\n"
|
||||
)
|
||||
|
||||
job = {
|
||||
"id": "briefing-job",
|
||||
"name": "briefing",
|
||||
"prompt": "hello",
|
||||
"model": "perplexity/sonar-pro",
|
||||
"provider": "custom",
|
||||
"base_url": "http://127.0.0.1:4000/v1",
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "http://127.0.0.1:4000/v1",
|
||||
"api_key": "***",
|
||||
}
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime) as runtime_mock, \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
success, output, final_response, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert "ok" in output
|
||||
runtime_mock.assert_called_once_with(
|
||||
requested="custom",
|
||||
explicit_base_url="http://127.0.0.1:4000/v1",
|
||||
)
|
||||
assert mock_agent_cls.call_args.kwargs["model"] == "perplexity/sonar-pro"
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
|
||||
class TestRunJobSkillBacked:
|
||||
def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path):
|
||||
job = {
|
||||
"id": "skill-job",
|
||||
"name": "skill test",
|
||||
"prompt": "Check the feeds and summarize anything new.",
|
||||
"skill": "blogwatcher",
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "***",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("tools.skills_tool.skill_view", return_value=json.dumps({"success": True, "content": "# Blogwatcher\nFollow this skill."})), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
success, output, final_response, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
assert "cronjob" in (kwargs["disabled_toolsets"] or [])
|
||||
|
||||
prompt_arg = mock_agent.run_conversation.call_args.args[0]
|
||||
assert "blogwatcher" in prompt_arg
|
||||
assert "Follow this skill" in prompt_arg
|
||||
assert "Check the feeds and summarize anything new." in prompt_arg
|
||||
|
||||
def test_run_job_loads_multiple_skills_in_order(self, tmp_path):
|
||||
job = {
|
||||
"id": "multi-skill-job",
|
||||
"name": "multi skill test",
|
||||
"prompt": "Combine the results.",
|
||||
"skills": ["blogwatcher", "find-nearby"],
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
|
||||
def _skill_view(name):
|
||||
return json.dumps({"success": True, "content": f"# {name}\nInstructions for {name}."})
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "***",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("tools.skills_tool.skill_view", side_effect=_skill_view) as skill_view_mock, \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
success, output, final_response, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert skill_view_mock.call_count == 2
|
||||
assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "find-nearby"]
|
||||
|
||||
prompt_arg = mock_agent.run_conversation.call_args.args[0]
|
||||
assert prompt_arg.index("blogwatcher") < prompt_arg.index("find-nearby")
|
||||
assert "Instructions for blogwatcher." in prompt_arg
|
||||
assert "Instructions for find-nearby." in prompt_arg
|
||||
assert "Combine the results." in prompt_arg
|
||||
|
||||
|
||||
class TestSilentDelivery:
|
||||
"""Verify that [SILENT] responses suppress delivery while still saving output."""
|
||||
|
||||
def _make_job(self):
|
||||
return {
|
||||
"id": "monitor-job",
|
||||
"name": "monitor",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
|
||||
def test_normal_response_delivers(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "Results here", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_called_once()
|
||||
|
||||
def test_silent_response_suppresses_delivery(self, caplog):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
with caplog.at_level(logging.INFO, logger="cron.scheduler"):
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_not_called()
|
||||
assert any(SILENT_MARKER in r.message for r in caplog.records)
|
||||
|
||||
def test_silent_with_note_suppresses_delivery(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT] No changes detected", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_not_called()
|
||||
|
||||
def test_silent_is_case_insensitive(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "[silent] nothing new", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_not_called()
|
||||
|
||||
def test_failed_job_always_delivers(self):
|
||||
"""Failed jobs deliver regardless of [SILENT] in output."""
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(False, "# output", "", "some error")), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_called_once()
|
||||
|
||||
def test_output_saved_even_when_delivery_suppressed(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# full output", "[SILENT]", None)), \
|
||||
patch("cron.scheduler.save_job_output") as save_mock, \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
save_mock.return_value = "/tmp/out.md"
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
save_mock.assert_called_once_with("monitor-job", "# full output")
|
||||
deliver_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestBuildJobPromptSilentHint:
|
||||
"""Verify _build_job_prompt always injects [SILENT] guidance."""
|
||||
|
||||
def test_hint_always_present(self):
|
||||
job = {"prompt": "Check for updates"}
|
||||
result = _build_job_prompt(job)
|
||||
assert "[SILENT]" in result
|
||||
assert "Check for updates" in result
|
||||
|
||||
def test_hint_present_even_without_prompt(self):
|
||||
job = {"prompt": ""}
|
||||
result = _build_job_prompt(job)
|
||||
assert "[SILENT]" in result
|
||||
|
||||
|
||||
class TestBuildJobPromptMissingSkill:
|
||||
"""Verify that a missing skill logs a warning and does not crash the job."""
|
||||
|
||||
def _missing_skill_view(self, name: str) -> str:
|
||||
return json.dumps({"success": False, "error": f"Skill '{name}' not found."})
|
||||
|
||||
def test_missing_skill_does_not_raise(self):
|
||||
"""Job should run even when a referenced skill is not installed."""
|
||||
with patch("tools.skills_tool.skill_view", side_effect=self._missing_skill_view):
|
||||
result = _build_job_prompt({"skills": ["ghost-skill"], "prompt": "do something"})
|
||||
# prompt is preserved even though skill was skipped
|
||||
assert "do something" in result
|
||||
|
||||
def test_missing_skill_injects_user_notice_into_prompt(self):
|
||||
"""A system notice about the missing skill is injected into the prompt."""
|
||||
with patch("tools.skills_tool.skill_view", side_effect=self._missing_skill_view):
|
||||
result = _build_job_prompt({"skills": ["ghost-skill"], "prompt": "do something"})
|
||||
assert "ghost-skill" in result
|
||||
assert "not found" in result.lower() or "skipped" in result.lower()
|
||||
|
||||
def test_missing_skill_logs_warning(self, caplog):
|
||||
"""A warning is logged when a skill cannot be found."""
|
||||
with caplog.at_level(logging.WARNING, logger="cron.scheduler"):
|
||||
with patch("tools.skills_tool.skill_view", side_effect=self._missing_skill_view):
|
||||
_build_job_prompt({"name": "My Job", "skills": ["ghost-skill"], "prompt": "do something"})
|
||||
assert any("ghost-skill" in record.message for record in caplog.records)
|
||||
|
||||
def test_valid_skill_loaded_alongside_missing(self):
|
||||
"""A valid skill is still loaded when another skill in the list is missing."""
|
||||
|
||||
def _mixed_skill_view(name: str) -> str:
|
||||
if name == "real-skill":
|
||||
return json.dumps({"success": True, "content": "Real skill content."})
|
||||
return json.dumps({"success": False, "error": f"Skill '{name}' not found."})
|
||||
|
||||
with patch("tools.skills_tool.skill_view", side_effect=_mixed_skill_view):
|
||||
result = _build_job_prompt({"skills": ["ghost-skill", "real-skill"], "prompt": "go"})
|
||||
assert "Real skill content." in result
|
||||
assert "go" in result
|
||||
Loading…
Add table
Add a link
Reference in a new issue