hermes-agent/tests/cron/test_scheduler.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

1903 lines
80 KiB
Python

"""Tests for cron/scheduler.py — origin resolution, delivery routing, and error logging."""
import contextlib
import itertools
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, _send_media_via_adapter, run_job, SILENT_MARKER, _build_job_prompt, _resolve_cron_enabled_toolsets, _merge_mcp_into_per_job_toolsets
from tools.env_passthrough import clear_env_passthrough
from tools.credential_files import clear_credential_files
class TestPerJobToolsetMcpMerge:
"""A per-job enabled_toolsets allowlist must not silently drop MCP servers."""
CFG = {
"mcp_servers": {
"finnhub": {"enabled": True},
"playwright": {"enabled": True},
"disabled_one": {"enabled": False},
"string_enabled": {"enabled": "true"},
"not_a_dict": "ignored",
}
}
def _enabled_names(self):
return {"finnhub", "playwright", "string_enabled"}
def test_native_only_list_gets_all_enabled_mcp_servers(self):
result = _merge_mcp_into_per_job_toolsets(["web", "terminal"], self.CFG)
assert result[:2] == ["web", "terminal"]
assert set(result) == {"web", "terminal"} | self._enabled_names()
def test_explicit_mcp_name_is_treated_as_allowlist(self):
# User named one server -> add nothing further.
result = _merge_mcp_into_per_job_toolsets(["web", "finnhub"], self.CFG)
assert result == ["web", "finnhub"]
assert "playwright" not in result
def test_no_mcp_sentinel_opts_out_and_is_stripped(self):
result = _merge_mcp_into_per_job_toolsets(["web", "no_mcp"], self.CFG)
assert result == ["web"]
assert not (set(result) & self._enabled_names())
def test_resolver_empty_per_job_falls_through_to_platform(self):
# No per-job list -> must delegate to _get_platform_tools (the platform
# fallback), NOT the per-job merge. Stub the platform resolver and assert
# it is the path taken and its result is returned.
job = {"enabled_toolsets": None}
sentinel = ["web", "finnhub"]
with patch("hermes_cli.tools_config._get_platform_tools",
return_value=set(sentinel)) as m_platform:
result = _resolve_cron_enabled_toolsets(job, self.CFG)
m_platform.assert_called_once()
# _get_platform_tools args: (cfg, "cron")
assert m_platform.call_args[0][1] == "cron"
assert set(result) == set(sentinel)
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"
@pytest.mark.parametrize(
"non_dict_origin",
[
"combined-digest-replaces-x-and-y-20260503",
123,
["telegram", "12345"],
("platform", "chat_id"),
42.0,
],
)
def test_non_dict_origin_returns_none_instead_of_crashing(self, non_dict_origin):
"""Non-dict origins (provenance strings from hand-edited or migrated
jobs.json) must be treated as missing instead of crashing the
scheduler tick on ``origin.get('platform')`` with
``'str' object has no attribute 'get'`` (#18722).
Before this guard a job in this state crashed every fire attempt
forever; ``mark_job_run`` recorded the error but the next tick
re-loaded the poisoned origin and crashed identically.
"""
job = {"origin": non_dict_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_bare_platform_delivery_uses_home_root_instead_of_origin_thread(self, monkeypatch):
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "home-parent")
monkeypatch.delenv("DISCORD_HOME_CHANNEL_THREAD_ID", raising=False)
job = {
"deliver": "discord",
"origin": {
"platform": "discord",
"chat_id": "origin-parent",
"thread_id": "origin-thread",
},
}
assert _resolve_delivery_target(job) == {
"platform": "discord",
"chat_id": "home-parent",
"thread_id": None,
}
def test_telegram_cron_thread_id_overrides_home_thread_id(self, monkeypatch):
"""TELEGRAM_CRON_THREAD_ID wins over TELEGRAM_HOME_CHANNEL_THREAD_ID for cron (#24409)."""
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-1001234567890")
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_THREAD_ID", "5")
monkeypatch.setenv("TELEGRAM_CRON_THREAD_ID", "42")
assert _resolve_delivery_target({"deliver": "telegram"}) == {
"platform": "telegram",
"chat_id": "-1001234567890",
"thread_id": "42",
}
def test_explicit_telegram_topic_target_overrides_cron_thread_id(self, monkeypatch):
"""Explicit ``telegram:chat:thread`` targets bypass TELEGRAM_CRON_THREAD_ID."""
monkeypatch.setenv("TELEGRAM_CRON_THREAD_ID", "999")
job = {"deliver": "telegram:-1003724596514:17"}
assert _resolve_delivery_target(job) == {
"platform": "telegram",
"chat_id": "-1003724596514",
"thread_id": "17",
}
def test_human_friendly_label_resolved_via_channel_directory(self):
"""deliver: 'whatsapp:Alice (dm)' resolves to the real JID."""
job = {"deliver": "whatsapp:Alice (dm)"}
with patch(
"gateway.channel_directory.resolve_channel_name",
return_value="12345678901234@lid",
) as resolve_mock:
result = _resolve_delivery_target(job)
resolve_mock.assert_called_once_with("whatsapp", "Alice (dm)")
assert result == {
"platform": "whatsapp",
"chat_id": "12345678901234@lid",
"thread_id": None,
}
def test_raw_id_not_mangled_when_directory_returns_none(self):
"""deliver: 'whatsapp:12345@lid' passes through when directory has no match."""
job = {"deliver": "whatsapp:12345@lid"}
with patch(
"gateway.channel_directory.resolve_channel_name",
return_value=None,
):
result = _resolve_delivery_target(job)
assert result == {
"platform": "whatsapp",
"chat_id": "12345@lid",
"thread_id": None,
}
def test_list_form_deliver_is_normalized(self, monkeypatch):
"""deliver=['telegram'] (Python list) should resolve like 'telegram' string.
Regression test for #17139: MCP clients / scripts that pass the deliver
field as an array-shaped value used to fail with "no delivery target
resolved for deliver=['telegram']" because ``str(['telegram'])`` was
passed through to ``split(',')`` verbatim.
"""
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-4004")
job = {
"deliver": ["telegram"],
"origin": None,
}
assert _resolve_delivery_target(job) == {
"platform": "telegram",
"chat_id": "-4004",
"thread_id": None,
}
class TestRoutingIntents:
"""``all`` routing intent expands at fire time."""
def test_all_expands_to_every_connected_home_channel(self, monkeypatch):
"""deliver='all' fans out to every platform with a configured home channel."""
from cron.scheduler import _resolve_delivery_targets
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111")
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222")
monkeypatch.setenv("SLACK_HOME_CHANNEL", "C333")
# Sanity: platforms without the env var must NOT appear in the expansion.
monkeypatch.delenv("SIGNAL_HOME_CHANNEL", raising=False)
monkeypatch.delenv("MATRIX_HOME_ROOM", raising=False)
targets = _resolve_delivery_targets({"deliver": "all", "origin": None})
platforms = sorted(t["platform"] for t in targets)
assert "telegram" in platforms
assert "discord" in platforms
assert "slack" in platforms
assert "signal" not in platforms
assert "matrix" not in platforms
class TestDeliverResultWrapping:
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"):
root = tmp_path / "media-cache"
media_file = root / name
media_file.parent.mkdir(parents=True, exist_ok=True)
media_file.write_bytes(data)
monkeypatch.setattr(
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
(root,),
)
return media_file.resolve()
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 "(job_id: test-job)" in sent_content
assert "-------------" in sent_content
assert "Here is today's summary." in sent_content
assert "To stop or manage this job" in sent_content
def test_relay_fronted_home_uses_relay_config_and_live_adapter(self, monkeypatch, tmp_path):
"""Persisted Slack home survives restart without native Slack config."""
from concurrent.futures import Future
from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig
relay = MagicMock()
relay.fronts_platform.side_effect = lambda platform: platform == Platform.SLACK
relay.send_for_platform = AsyncMock(return_value=MagicMock(success=True))
relay.send_voice = AsyncMock(return_value=MagicMock(success=True))
relay.supports_inchannel_continuable = False
config = GatewayConfig(
platforms={
Platform.RELAY: PlatformConfig(enabled=True),
Platform.SLACK: PlatformConfig(
enabled=False,
home_channel=HomeChannel(
platform=Platform.SLACK,
chat_id="D123",
name="Owner DM",
user_id="U123",
),
),
},
)
loop = MagicMock()
loop.is_running.return_value = True
def fake_run_coro(coro, _loop):
import asyncio as _asyncio
future = Future()
try:
future.set_result(_asyncio.run(coro))
except BaseException as exc: # noqa: BLE001
future.set_exception(exc)
return future
standalone_send = AsyncMock(return_value={"success": True})
media_path = self._safe_media_path(tmp_path, monkeypatch, "relay-voice.mp3")
monkeypatch.setenv("SLACK_HOME_CHANNEL", "D123")
job = {
"id": "relay-cron",
"deliver": "slack",
}
with (
patch("gateway.config.load_gateway_config", return_value=config),
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}),
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro),
patch("tools.send_message_tool._send_to_platform", new=standalone_send),
):
result = _deliver_result(
job,
f"scheduled result\nMEDIA:{media_path}",
adapters={Platform.RELAY: relay},
loop=loop,
)
assert result is None
relay.send_for_platform.assert_awaited_once()
args = relay.send_for_platform.await_args.args
assert args[:3] == (Platform.SLACK, "D123", "scheduled result")
assert relay.send_for_platform.await_args.kwargs["metadata"]["user_id"] == "U123"
relay.send_voice.assert_awaited_once()
media_metadata = relay.send_voice.await_args.kwargs["metadata"]
assert media_metadata["_relay_logical_platform"] == "slack"
assert media_metadata["user_id"] == "U123"
standalone_send.assert_not_awaited()
def test_live_adapter_sends_media_as_attachments(self, tmp_path, monkeypatch):
"""When a live adapter is available, MEDIA files should be sent as native
platform attachments (e.g., Discord voice, Telegram audio) rather than
as literal 'MEDIA:/path' text."""
from gateway.config import Platform
from concurrent.futures import Future
media_path = self._safe_media_path(tmp_path, monkeypatch, "cron-voice.mp3")
adapter = AsyncMock()
adapter.send.return_value = MagicMock(success=True)
adapter.send_voice.return_value = MagicMock(success=True)
pconfig = MagicMock()
pconfig.enabled = True
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.DISCORD: pconfig}
loop = MagicMock()
loop.is_running.return_value = True
# run_coroutine_threadsafe returns concurrent.futures.Future (has timeout kwarg)
def fake_run_coro(coro, _loop):
# Actually run the routed coroutine (router._deliver_to_platform)
# so the underlying adapter.send is invoked, then wrap the real
# result in a completed Future (matching run_coroutine_threadsafe).
import asyncio as _asyncio
future = Future()
try:
future.set_result(_asyncio.run(coro))
except BaseException as _e: # noqa: BLE001
future.set_exception(_e)
return future
job = {
"id": "tts-job",
"deliver": "origin",
"origin": {"platform": "discord", "chat_id": "9876"},
}
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
_deliver_result(
job,
f"Here is TTS\nMEDIA:{media_path}",
adapters={Platform.DISCORD: adapter},
loop=loop,
)
# Text should be sent without the MEDIA tag
adapter.send.assert_called_once()
text_sent = adapter.send.call_args[0][1]
assert "MEDIA:" not in text_sent
assert "Here is TTS" in text_sent
# Audio file should be sent as a voice attachment
adapter.send_voice.assert_called_once()
voice_call = adapter.send_voice.call_args
assert voice_call[1]["audio_path"] == str(media_path)
class TestDeliverResultErrorReturns:
"""Verify _deliver_result returns error strings on failure, None on success."""
def test_returns_error_when_platform_disabled(self):
from gateway.config import Platform
pconfig = MagicMock()
pconfig.enabled = False
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
with patch("gateway.config.load_gateway_config", return_value=mock_cfg):
job = {
"id": "disabled",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
}
result = _deliver_result(job, "Output.")
assert result is not None
assert "not configured" in result
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()
fake_db.get_compression_tip.side_effect = lambda session_id: session_id
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
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_")
original_session_id = kwargs["session_id"]
fake_db.get_compression_tip.assert_called_once_with(original_session_id)
fake_db.end_session.assert_called_once()
call_args = fake_db.end_session.call_args
assert call_args[0][0] == original_session_id
assert call_args[0][1] == "cron_complete"
fake_db.close.assert_called_once()
mock_agent.close.assert_called_once()
@contextlib.contextmanager
def _run_job_patches(self, tmp_path, extra=()):
"""Apply every patch run_job tests need, as one bundle.
Yields ``(fake_db, mock_agent_cls)``. Using an ExitStack that enters
the whole list means a caller can never silently drop a patch by
index — the previous positional-list form let a seam split shift
``resolve_runtime_provider`` off the end of the applied slice, so the
real resolver ran and (only on a dev machine with ambient creds) hid
an auth failure that CI then caught. Every test enters all patches.
``extra`` is an iterable of additional context managers (e.g. a
per-test ``_get_platform_tools`` patch) entered alongside the base set.
"""
fake_db = MagicMock()
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ok"}
base = [
patch("cron.scheduler._hermes_home", tmp_path),
patch("cron.scheduler._resolve_origin", return_value=None),
patch("hermes_cli.env_loader.load_hermes_dotenv"),
patch("hermes_cli.env_loader.reset_secret_source_cache"),
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", return_value=mock_agent),
]
with contextlib.ExitStack() as stack:
entered = [stack.enter_context(cm) for cm in base]
for cm in extra:
stack.enter_context(cm)
mock_agent_cls = entered[-1] # the AIAgent patch
yield fake_db, mock_agent_cls
def test_tick_skips_due_jobs_while_dispatch_is_paused(self, tmp_path):
"""The drain gate runs before advancing a due job's schedule."""
from cron.scheduler import tick
job = {
"id": "paused-due-job",
"name": "paused due job",
"schedule": {"kind": "interval", "seconds": 60},
"next_run_at": "2020-01-01T00:00:00+00:00",
"enabled": True,
}
with patch("cron.scheduler.get_due_jobs", return_value=[job]), patch(
"cron.scheduler.advance_next_run"
) as advance, patch("cron.scheduler.run_one_job") as run_one:
assert tick(verbose=False, sync=True, can_dispatch=lambda: False) == 0
advance.assert_not_called()
run_one.assert_not_called()
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",
}
# Mock heavy post-yaml work so the test only exercises the warning
# path. Without these mocks, run_job continues into provider
# resolution and MCP discovery, both of which can spawn subprocesses
# / hit the network and have caused this test to time out on CI
# (>30s wall clock) under load. See PR #33661 follow-up.
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={"provider": "openrouter", "api_key": "x",
"base_url": "https://example.invalid",
"api_mode": "chat_completions"}), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
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]}"
class TestRunJobConfigEnvVarExpansion:
"""Verify that ${VAR} references in config.yaml are expanded when running cron jobs."""
_RUNTIME = {
"api_key": "test-key",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
}
def test_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):
"""${VAR} in config.yaml model: is expanded using env after .env is loaded."""
(tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_MODEL}\n")
monkeypatch.setenv("_HERMES_TEST_CRON_MODEL", "gpt-4o-mini-cron-test")
job = {"id": "env-job", "name": "env test", "prompt": "hi"}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
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, _, _, error = run_job(job)
assert success is True
assert error is None
kwargs = mock_agent_cls.call_args.kwargs
assert kwargs["model"] == "gpt-4o-mini-cron-test", (
f"Expected model='gpt-4o-mini-cron-test', got {kwargs['model']!r}. "
"config.yaml ${VAR} was not expanded in the cron execution path."
)
def test_auth_fallback_switches_provider_and_model_together(self, tmp_path):
"""Codex auth failure must produce OpenRouter+GLM, never OpenRouter+GPT."""
from hermes_cli.auth import AuthError
(tmp_path / "config.yaml").write_text(
"model:\n"
" default: gpt-5.6-sol\n"
" provider: openai-codex\n"
"fallback_providers:\n"
" - provider: anthropic\n"
" - provider: openrouter\n"
" model: z-ai/glm-5.2\n",
encoding="utf-8",
)
job = {
"id": "auth-fallback",
"name": "auth fallback",
"prompt": "hi",
"provider_snapshot": "openai-codex",
"model_snapshot": "gpt-5.6-sol",
}
fake_db = MagicMock()
requested = []
def resolve_runtime(**kwargs):
requested.append(kwargs.get("requested"))
if kwargs.get("requested") in (None, "openai-codex"):
# Cron must retain the configured primary provider for drift
# comparison even when older/custom AuthError sites omit it.
raise AuthError("No Codex credentials stored")
assert kwargs["requested"] == "openrouter"
assert kwargs["target_model"] == "z-ai/glm-5.2"
return {**self._RUNTIME, "provider": "openrouter"}
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=resolve_runtime), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
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, _, _, error = run_job(job)
assert success is True
assert error is None
assert requested == [None, "openrouter"]
kwargs = mock_agent_cls.call_args.kwargs
assert kwargs["provider"] == "openrouter"
assert kwargs["model"] == "z-ai/glm-5.2"
def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch):
"""When the env var is not set, the literal ${VAR} is kept verbatim (not crashed)."""
(tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_UNSET_VAR}\n")
monkeypatch.delenv("_HERMES_TEST_CRON_UNSET_VAR", raising=False)
job = {"id": "unset-job", "name": "unset var test", "prompt": "hi"}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
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, _, _, error = run_job(job)
assert success is True
kwargs = mock_agent_cls.call_args.kwargs
# Unresolved refs are kept verbatim — _expand_env_vars contract
assert kwargs["model"] == "${_HERMES_TEST_CRON_UNSET_VAR}"
class TestRunJobModelResolution:
"""Verify defensive model resolution for jobs stored with ``model: null``.
Issue #23979: a cron job created without an explicit model is stored as
``model: null``. At fire time the scheduler must:
1. fall back to ``HERMES_MODEL`` env if set,
2. else fall back to config.yaml ``model.default`` if set,
3. else fail fast with an actionable error — never let an empty string
reach the provider where it surfaces as an opaque 400.
"""
_RUNTIME = {
"api_key": "test-key",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
}
def test_null_job_model_falls_back_to_env(self, tmp_path, monkeypatch):
"""``model: null`` on the job uses HERMES_MODEL when set."""
(tmp_path / "config.yaml").write_text("")
monkeypatch.setenv("HERMES_MODEL", "env-model")
job = {"id": "null-model-job", "name": "null model", "prompt": "hi", "model": None}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
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, _, _, error = run_job(job)
assert success is True
assert error is None
assert mock_agent_cls.call_args.kwargs["model"] == "env-model"
def test_no_model_anywhere_fails_with_actionable_error(self, tmp_path, monkeypatch):
"""All three sources empty → fail fast with a clear message, not an opaque 400."""
(tmp_path / "config.yaml").write_text("")
monkeypatch.delenv("HERMES_MODEL", raising=False)
job = {"id": "no-model-job", "name": "no model anywhere", "prompt": "hi", "model": None}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls:
success, _, _, error = run_job(job)
assert success is False
assert error is not None
assert "no model configured" in error
# AIAgent must never be constructed with an empty model — that's
# precisely the bug we're guarding against.
mock_agent_cls.assert_not_called()
def test_config_model_alias_key_resolves(self, tmp_path, monkeypatch):
"""A ``model: {model: ...}`` alias key resolves like the CLI sibling.
``hermes_cli/oneshot.py``, ``fallback_cmd.py`` and ``prompt_size.py``
all accept ``model.model`` as an alias for ``model.default``. The cron
resolver mirrors that so a config that works in the CLI also works in
cron.
"""
(tmp_path / "config.yaml").write_text("model:\n model: alias-key-model\n")
monkeypatch.delenv("HERMES_MODEL", raising=False)
job = {"id": "alias-job", "name": "alias", "prompt": "hi", "model": None}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
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, _, _, error = run_job(job)
assert success is True
assert error is None
assert mock_agent_cls.call_args.kwargs["model"] == "alias-key-model"
def test_corrupt_config_yaml_does_not_crash_with_job_model(self, tmp_path, monkeypatch):
"""A malformed config.yaml degrades gracefully when the job has a model."""
(tmp_path / "config.yaml").write_text("{{{invalid yaml!!!")
monkeypatch.delenv("HERMES_MODEL", raising=False)
job = {"id": "corrupt-job", "name": "corrupt", "prompt": "hi", "model": "explicit-model"}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
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, _, _, error = run_job(job)
# Explicit job model survives the corrupt-config fall-through.
assert success is True
assert error is None
assert mock_agent_cls.call_args.kwargs["model"] == "explicit-model"
class TestRunJobSkillBacked:
def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path):
job = {
"id": "skill-env-job",
"name": "skill env test",
"prompt": "Use the skill.",
"skill": "notion",
}
fake_db = MagicMock()
def _skill_view(name):
assert name == "notion"
from tools.env_passthrough import register_env_passthrough
register_env_passthrough(["NOTION_API_KEY"])
return json.dumps({"success": True, "content": "# notion\nUse Notion."})
def _run_conversation(prompt):
from tools.env_passthrough import get_all_passthrough
assert "NOTION_API_KEY" in get_all_passthrough()
return {"final_response": "ok"}
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
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), \
patch("run_agent.AIAgent") as mock_agent_cls:
mock_agent = MagicMock()
mock_agent.run_conversation.side_effect = _run_conversation
mock_agent_cls.return_value = mock_agent
try:
success, output, final_response, error = run_job(job)
finally:
clear_env_passthrough()
assert success is True
assert error is None
assert final_response == "ok"
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_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_report_quoting_marker_mid_sentence_still_delivers(self):
"""A genuine report that merely mentions the token mid-sentence must
be delivered — the old substring check wrongly swallowed it."""
response = "I considered staying [SILENT] but here is the summary: 3 items merged."
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
patch("cron.scheduler.run_job", return_value=(True, "# output", response, 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_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_whitespace_only_response_is_marked_failed_not_delivered(self):
"""Whitespace-only final responses should behave like empty responses."""
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
patch("cron.scheduler.run_job", return_value=(True, "# output", " \n\t ", 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") as mark_mock:
from cron.scheduler import tick
tick(verbose=False)
deliver_mock.assert_not_called()
mark_mock.assert_called_once_with(
"monitor-job",
False,
"Agent completed but produced empty response (model error, timeout, or misconfiguration)",
delivery_error=None,
)
class TestOneShotDispatchClaim:
"""run_one_job must claim a finite one-shot's dispatch BEFORE run_job so a
tick that dies mid-execution can't re-fire it forever (issue #38758)."""
def _oneshot(self):
return {
"id": "monitor-job",
"name": "monitor",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
"schedule": {"kind": "once", "run_at": "2026-01-01T00:00:00+00:00"},
"repeat": {"times": 1, "completed": 0},
}
def test_claim_runs_before_run_job(self):
order = []
with patch("cron.scheduler.get_due_jobs", return_value=[self._oneshot()]), \
patch("cron.scheduler.claim_dispatch", side_effect=lambda _id: order.append("claim") or True), \
patch("cron.scheduler.run_job", side_effect=lambda _j, **_kw: order.append("run") or (True, "# out", "ok", None)), \
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
patch("cron.scheduler._deliver_result"), \
patch("cron.scheduler.mark_job_run"):
from cron.scheduler import tick
tick(verbose=False)
assert order == ["claim", "run"] # claim strictly before side effect
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
class TestParseWakeGate:
"""Unit tests for _parse_wake_gate — pure function, no side effects."""
def test_empty_output_wakes(self):
from cron.scheduler import _parse_wake_gate
assert _parse_wake_gate("") is True
assert _parse_wake_gate(None) is True
def test_wake_gate_false_skips(self):
from cron.scheduler import _parse_wake_gate
assert _parse_wake_gate('{"wakeAgent": false}') is False
class TestRunJobWakeGate:
"""Integration tests for run_job wake-gate short-circuit."""
@pytest.fixture(autouse=True)
def _stub_runtime_provider(self):
"""Stub ``resolve_runtime_provider`` for wake-gate tests.
``run_job`` resolves the runtime provider BEFORE constructing
``AIAgent``, so these tests must mock ``resolve_runtime_provider``
in addition to ``AIAgent`` — otherwise in a hermetic CI env (no
API keys), the resolver raises and the test fails before the
patched AIAgent is ever reached.
"""
fake_runtime = {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "test-key",
"source": "stub",
"requested_provider": None,
}
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
):
yield
def _make_job(self, name="wake-gate-test", script="check.py"):
"""Minimal valid cron job dict for run_job."""
return {
"id": f"job_{name}",
"name": name,
"prompt": "Do a thing",
"schedule": "*/5 * * * *",
"script": script,
}
def test_wake_false_skips_agent_and_returns_silent(self, caplog):
"""When _run_job_script output ends with {wakeAgent: false}, the agent
is not invoked and run_job returns the SILENT marker so delivery is
suppressed."""
from cron.scheduler import SILENT_MARKER
import cron.scheduler as scheduler
with patch.object(scheduler, "_run_job_script",
return_value=(True, '{"wakeAgent": false}')), \
patch("run_agent.AIAgent") as agent_cls:
success, doc, final, err = scheduler.run_job(self._make_job())
assert success is True
assert err is None
assert final == SILENT_MARKER
assert "Script gate returned `wakeAgent=false`" in doc
agent_cls.assert_not_called()
def test_wake_true_runs_agent_with_injected_output(self):
"""When the script returns {wakeAgent: true, data: ...}, the agent is
invoked and the data line still shows up in the prompt."""
import cron.scheduler as scheduler
script_output = '{"wakeAgent": true, "data": {"new": 3}}'
agent = MagicMock()
agent.run_conversation = MagicMock(return_value={
"final_response": "ok", "messages": []
})
with patch.object(scheduler, "_run_job_script",
return_value=(True, script_output)), \
patch("run_agent.AIAgent", return_value=agent) as agent_cls:
success, doc, final, err = scheduler.run_job(self._make_job())
agent_cls.assert_called_once()
# The script output should be visible in the prompt passed to
# run_conversation.
call_kwargs = agent.run_conversation.call_args
prompt_arg = call_kwargs.args[0] if call_kwargs.args else call_kwargs.kwargs.get("user_message", "")
assert script_output in prompt_arg
assert success is True
assert err is None
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_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()
class TestBuildJobPromptAbsoluteSkillPath:
"""Cron jobs may store absolute skill paths; normalize before skill_view."""
def test_absolute_skill_path_normalized_before_skill_view(self, tmp_path):
skills_dir = tmp_path / "skills"
skill_dir = skills_dir / "alpha-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# Alpha\nDo alpha.")
absolute_path = str(skill_dir)
seen_names: list[str] = []
def _skill_view(name: str) -> str:
seen_names.append(name)
if name == "alpha-skill":
return json.dumps({"success": True, "content": "# Alpha\nDo alpha."})
return json.dumps({"success": False, "error": f"Skill '{name}' not found."})
with patch("tools.skills_tool.SKILLS_DIR", skills_dir), \
patch("tools.skills_tool.skill_view", side_effect=_skill_view):
result = _build_job_prompt({"skills": [absolute_path], "prompt": "go"})
assert seen_names == ["alpha-skill"]
assert "Do alpha." in result
class TestBuildJobPromptBumpUse:
"""Verify that cron jobs bump skill usage counters so the curator sees them as active."""
def test_bump_use_called_for_loaded_skill(self):
"""bump_use is called for each successfully loaded skill."""
def _skill_view(name: str) -> str:
return json.dumps({"success": True, "content": f"Content for {name}."})
with patch("tools.skills_tool.skill_view", side_effect=_skill_view), \
patch("tools.skill_usage.bump_use") as mock_bump:
_build_job_prompt({"skills": ["alpha", "beta"], "prompt": "go"})
assert mock_bump.call_count == 2
calls = [c[0][0] for c in mock_bump.call_args_list]
assert "alpha" in calls
assert "beta" in calls
class TestSendMediaViaAdapter:
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""
def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"):
root = tmp_path / "media-cache"
media_file = root / name
media_file.parent.mkdir(parents=True, exist_ok=True)
media_file.write_bytes(data)
monkeypatch.setattr(
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
(root,),
)
return media_file.resolve()
@staticmethod
def _run_with_loop(adapter, chat_id, media_files, metadata, job):
"""Helper: run _send_media_via_adapter with immediate scheduling."""
from concurrent.futures import Future
def fake_run_coro(coro, _loop):
coro.close()
completed = Future()
completed.set_result(MagicMock(success=True))
return completed
with patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
_send_media_via_adapter(adapter, chat_id, media_files, metadata, MagicMock(), job)
def test_multiple_media_files_all_delivered(self, tmp_path, monkeypatch):
adapter = MagicMock()
adapter.send_voice = AsyncMock()
adapter.send_image_file = AsyncMock()
voice_path = self._safe_media_path(tmp_path, monkeypatch, "voice.mp3")
photo_path = self._safe_media_path(tmp_path, monkeypatch, "photo.jpg")
media_files = [(str(voice_path), False), (str(photo_path), False)]
self._run_with_loop(adapter, "123", media_files, None, {"id": "j3"})
adapter.send_voice.assert_called_once()
adapter.send_image_file.assert_called_once()
class TestParallelTick:
"""Verify that tick() runs due jobs concurrently and isolates ContextVars."""
@pytest.fixture(autouse=True)
def _isolate_tick_lock(self, tmp_path):
"""Point the tick file lock at a per-test temp dir to avoid xdist contention."""
lock_dir = tmp_path / "cron"
lock_dir.mkdir()
lock_file = lock_dir / ".tick.lock"
with patch("cron.scheduler._get_lock_paths", return_value=(lock_dir, lock_file)):
yield
def test_parallel_jobs_run_concurrently(self):
"""Two jobs launched in the same tick should overlap in time."""
import threading
barrier = threading.Barrier(2, timeout=5)
call_order = []
def mock_run_job(job, *, defer_agent_teardown=None):
"""Each job hits a barrier — both must be active simultaneously."""
call_order.append(("start", job["id"]))
barrier.wait() # blocks until both threads reach here
call_order.append(("end", job["id"]))
return (True, "output", "response", None)
jobs = [
{"id": "job-a", "name": "a", "deliver": "local"},
{"id": "job-b", "name": "b", "deliver": "local"},
]
with patch("cron.scheduler.get_due_jobs", return_value=jobs), \
patch("cron.scheduler.advance_next_run"), \
patch("cron.scheduler.run_job", side_effect=mock_run_job), \
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
patch("cron.scheduler._deliver_result", return_value=None), \
patch("cron.scheduler.mark_job_run"):
from cron.scheduler import tick
result = tick(verbose=False)
assert result == 2
# Both starts happened before both ends — proof of concurrency
starts = [i for i, (action, _) in enumerate(call_order) if action == "start"]
ends = [i for i, (action, _) in enumerate(call_order) if action == "end"]
assert len(starts) == 2
assert len(ends) == 2
assert max(starts) < min(ends), f"Jobs not concurrent: {call_order}"
def test_parallel_jobs_isolated_contextvars(self):
"""Each job's ContextVars must be isolated — no cross-contamination."""
from gateway.session_context import get_session_env
seen = {}
def mock_run_job(job, *, defer_agent_teardown=None):
origin = job.get("origin", {})
# run_job sets ContextVars — verify each job sees its own
from gateway.session_context import set_session_vars, clear_session_vars
tokens = set_session_vars(
platform=origin.get("platform", ""),
chat_id=str(origin.get("chat_id", "")),
)
import time
time.sleep(0.05) # give other thread time to set its vars
platform = get_session_env("HERMES_SESSION_PLATFORM")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
seen[job["id"]] = {"platform": platform, "chat_id": chat_id}
clear_session_vars(tokens)
return (True, "output", "response", None)
jobs = [
{"id": "tg-job", "name": "tg", "deliver": "local",
"origin": {"platform": "telegram", "chat_id": "111"}},
{"id": "dc-job", "name": "dc", "deliver": "local",
"origin": {"platform": "discord", "chat_id": "222"}},
]
with patch("cron.scheduler.get_due_jobs", return_value=jobs), \
patch("cron.scheduler.advance_next_run"), \
patch("cron.scheduler.run_job", side_effect=mock_run_job), \
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
patch("cron.scheduler._deliver_result", return_value=None), \
patch("cron.scheduler.mark_job_run"):
from cron.scheduler import tick
tick(verbose=False)
assert seen["tg-job"] == {"platform": "telegram", "chat_id": "111"}
assert seen["dc-job"] == {"platform": "discord", "chat_id": "222"}
class TestDeliverResultTimeoutCancelsFuture:
"""When future.result(timeout=60) raises TimeoutError in the live adapter
delivery path, the outcome depends on whether the coroutine was already
running. future.cancel() returning False means it is in flight on the wire
(cannot be un-sent) → treat as DELIVERED and skip the standalone fallback to
avoid a duplicate (#38922). future.cancel() returning True means it never
started (wedged loop) → nothing was sent, so fall through to standalone or
the message is silently dropped. Regression for #38922.
"""
def test_live_adapter_timeout_assumes_delivered_no_duplicate(self):
"""End-to-end: live adapter confirmation times out past the 60s budget.
The fix (#38922) treats the send as already-dispatched/delivered and
does NOT run the standalone fallback — otherwise the message is sent
twice."""
from gateway.config import Platform
from concurrent.futures import Future
# Live adapter whose send() coroutine never resolves within the budget
adapter = AsyncMock()
adapter.send.return_value = MagicMock(success=True)
pconfig = MagicMock()
pconfig.enabled = True
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
loop = MagicMock()
loop.is_running.return_value = True
# A real concurrent.futures.Future, but we override .result() to raise
# TimeoutError exactly like the 60s wait firing in production. We make
# .cancel() return False to simulate the coroutine being ALREADY RUNNING
# on the gateway loop (in flight on the wire) — the case where the send
# cannot be un-sent and a standalone resend would be a duplicate.
captured_future = Future()
cancel_calls = []
def in_flight_cancel():
cancel_calls.append(True)
return False # already running — cannot be cancelled
captured_future.cancel = in_flight_cancel
captured_future.result = MagicMock(side_effect=TimeoutError("timed out"))
def fake_run_coro(coro, _loop):
coro.close()
return captured_future
job = {
"id": "timeout-job",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
}
standalone_send = AsyncMock(return_value={"success": True})
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro), \
patch("tools.send_message_tool._send_to_platform", new=standalone_send):
result = _deliver_result(
job,
"Hello world",
adapters={Platform.TELEGRAM: adapter},
loop=loop,
)
# 1. cancel() was attempted (returned False = in flight).
assert cancel_calls == [True], "future.cancel() should be attempted on TimeoutError"
# 2. Delivery is reported successful (no error string returned).
assert result is None, f"expected successful delivery, got error: {result!r}"
# 3. The standalone fallback must NOT run — that is the #38922 fix:
# an in-flight confirmation timeout is assume-delivered, not a resend.
standalone_send.assert_not_awaited()
class TestDeliverResultLiveAdapterUnconfirmed:
"""Regression for #47056.
When a live adapter's send() returns ``None`` (swallowed exception / busy
platform) or a result object that lacks an explicit ``success`` attribute
(bare dict / partial object), the scheduler must NOT log "delivered via
live adapter" and silently drop the message. Every unconfirmed shape must
fall through to the standalone delivery path so the message actually
arrives. The pre-fix check ``send_result is None or not getattr(...,
"success", True)`` let a ``.success``-less object default to True = silent
success.
"""
def _run(self, send_value):
from gateway.config import Platform
from concurrent.futures import Future
adapter = AsyncMock()
adapter.send.return_value = send_value
pconfig = MagicMock()
pconfig.enabled = True
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
loop = MagicMock()
loop.is_running.return_value = True
completed_future = Future()
completed_future.set_result(send_value)
def fake_run_coro(coro, _loop):
coro.close()
return completed_future
job = {
"id": "unconfirmed-job",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
}
standalone_send = AsyncMock(return_value={"success": True})
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro), \
patch("tools.send_message_tool._send_to_platform", new=standalone_send):
result = _deliver_result(
job,
"Hello world",
adapters={Platform.TELEGRAM: adapter},
loop=loop,
)
return result, standalone_send
def test_none_result_falls_through_to_standalone(self):
"""send() returning None must trigger the standalone fallback, not a
silent "delivered" log."""
result, standalone_send = self._run(None)
assert result is None, f"standalone should have delivered, got: {result!r}"
standalone_send.assert_awaited_once()
class TestDeliverOriginUnresolvableIsLocal:
"""Regression for #43014.
A cron job created in a CLI session has no {platform, chat_id} origin.
With ``deliver=origin`` (or auto-detect / deliver=None) and no configured
platform home channel, delivery is unresolvable — but that is the EXPECTED
state for CLI jobs, not an error. _deliver_result must return None (treat
as local; output stays in last_output), not the "no delivery target
resolved" error string that previously fired on every run.
"""
def _deliver(self, job, monkeypatch):
import cron.scheduler as sched
# No home channel for any platform → origin is unresolvable.
monkeypatch.setattr(sched, "_get_home_target_chat_id", lambda *_: "")
return _deliver_result(job, "CLI bulletin")
def test_origin_with_no_home_channels_returns_none(self, monkeypatch):
job = {"id": "cli-job", "deliver": "origin", "origin": "cli-session-provenance"}
assert self._deliver(job, monkeypatch) is None
class TestSendMediaTimeoutCancelsFuture:
"""Same orphan-coroutine guarantee for _send_media_via_adapter's
future.result(timeout=30) call. If this times out mid-batch, the
in-flight coroutine must be cancelled before the next file is tried.
"""
def test_media_send_timeout_cancels_future_and_continues(self, tmp_path, monkeypatch):
"""End-to-end: _send_media_via_adapter with a future whose .result()
raises TimeoutError. Assert cancel() fires and the loop proceeds
to the next file rather than hanging or crashing."""
from concurrent.futures import Future
adapter = MagicMock()
adapter.send_image_file = AsyncMock()
adapter.send_video = AsyncMock()
# First file: future that times out. Second file: future that resolves OK.
timeout_future = Future()
timeout_cancel_calls = []
original_cancel = timeout_future.cancel
def tracking_cancel():
timeout_cancel_calls.append(True)
return original_cancel()
timeout_future.cancel = tracking_cancel
timeout_future.result = MagicMock(side_effect=TimeoutError("timed out"))
ok_future = Future()
ok_future.set_result(MagicMock(success=True))
futures_iter = iter([timeout_future, ok_future])
def fake_run_coro(coro, _loop):
coro.close()
return next(futures_iter)
root = tmp_path / "media-cache"
slow = root / "slow.png"
fast = root / "fast.mp4"
slow.parent.mkdir(parents=True)
slow.write_bytes(b"slow")
fast.write_bytes(b"fast")
monkeypatch.setattr(
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
(root,),
)
media_files = [
(str(slow), False), # times out
(str(fast), False), # succeeds
]
loop = MagicMock()
job = {"id": "media-timeout"}
with patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
# Should not raise — the except Exception clause swallows the timeout
_send_media_via_adapter(adapter, "chat-1", media_files, None, loop, job)
# 1. The timed-out future was cancelled (the bug fix)
assert timeout_cancel_calls == [True], "future.cancel() must fire on TimeoutError"
# 2. Second file still got dispatched — one timeout doesn't abort the batch
adapter.send_video.assert_called_once()
assert adapter.send_video.call_args[1]["video_path"] == str(fast.resolve())
class TestCronDeliveryTargets:
"""``cron_delivery_targets`` powers the dashboard delivery dropdown.
It must list every configured + cron-deliverable platform (no hardcoded
set), flag whether each has its home channel set, and never include
platforms whose gateway isn't configured.
"""
def _patch_connected(self, monkeypatch, names):
import gateway.config as gateway_config
class _Platform:
def __init__(self, value):
self.value = value
class _GatewayConfig:
def get_connected_platforms(self_inner):
return [_Platform(n) for n in names]
monkeypatch.setattr(
gateway_config, "load_gateway_config", lambda: _GatewayConfig()
)
def test_lists_configured_platforms_flagging_missing_home_channel(self, monkeypatch):
from cron.scheduler import cron_delivery_targets
self._patch_connected(monkeypatch, ["matrix", "telegram"])
monkeypatch.delenv("MATRIX_HOME_ROOM", raising=False)
monkeypatch.delenv("TELEGRAM_HOME_CHANNEL", raising=False)
targets = {t["id"]: t for t in cron_delivery_targets()}
assert set(targets) == {"matrix", "telegram"}
# Configured but no home channel → surfaced, flagged for the UI.
assert targets["matrix"]["home_target_set"] is False
assert targets["matrix"]["home_env_var"] == "MATRIX_HOME_ROOM"
assert targets["telegram"]["home_target_set"] is False
class TestHomeTargetEnvVarRegistry:
"""Regression: ``_HOME_TARGET_ENV_VARS`` must include every gateway
platform that supports cron-driven outbound delivery. Missing an
entry means ``hermes cron create --deliver=<platform>`` silently
fails to route through the platform's home channel."""
class TestCronDeliveryMirror:
"""cron.mirror_delivery / per-job attach_to_session: opt-in append of a
cron delivery into the target chat's gateway session transcript.
Default OFF preserves the historical isolation guarantee byte-for-byte.
When enabled, delivery rides the existing gateway.mirror.mirror_to_session
so cron uses exactly the same path interactive send_message mirroring uses.
"""
def test_mirror_writes_user_role_with_label_not_assistant(self):
"""Regression for #2221 / #2313: the cron brief must mirror as a USER
turn (with a [Cron delivery: ...] label), NOT assistant — an
assistant-role mirror lands as assistant->assistant after the agent's
last turn and breaks strict alternation on non-Anthropic providers."""
from cron.scheduler import _maybe_mirror_cron_delivery
with patch("gateway.mirror.mirror_to_session", return_value=True) as m:
_maybe_mirror_cron_delivery(
{"id": "j1", "name": "Morning Brief"}, "telegram", "123",
"Market movers today", thread_id=None, enabled=True,
)
m.assert_called_once()
args, kwargs = m.call_args
assert kwargs.get("role") == "user", "cron mirror must be a user turn, not assistant"
# The brief text is prefixed with a human-readable cron-delivery label
# so replay (where the mirror metadata is dropped at the SQLite
# boundary) still distinguishes it from a genuine user message.
assert args[2].startswith("[Cron delivery: Morning Brief]")
assert "Market movers today" in args[2]
def test_delivery_mirrors_clean_content_not_wrapped(self):
"""When enabled, the mirror receives the CLEAN agent output, not the
cron header/footer-wrapped delivery text."""
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", return_value=True) as mirror_mock:
job = {
"id": "test-job",
"name": "daily-report",
"deliver": "origin",
"origin": {"platform": "telegram", "chat_id": "123"},
"attach_to_session": True,
}
_deliver_result(job, "Here is today's summary.")
mirror_mock.assert_called_once()
mirrored_text = mirror_mock.call_args[0][2]
# Clean content, no cron wrapper.
assert "Here is today's summary." in mirrored_text
assert "Cronjob Response:" not in mirrored_text
assert "To stop or manage this job" not in mirrored_text
# --- origin-scoping (mirror only into the conversation that created the job) ---
# --- multi-participant parity with send_message (user_id passthrough) ---
# --- continuable cron: thread-preferred (Teknium's interface) ---
def test_open_thread_returns_id_on_thread_platform(self):
"""On a thread-capable adapter, _open_continuable_cron_thread returns
the new thread id from create_handoff_thread."""
from cron.scheduler import _open_continuable_cron_thread
adapter = MagicMock()
adapter.create_handoff_thread = AsyncMock(return_value="9001")
# safe_schedule_threadsafe hands the coro to the gateway loop and
# returns a future. Patch it to close the coro and return a ready
# future carrying the adapter's thread id.
def _run_now(coro, _loop):
coro.close()
fut = MagicMock()
fut.result.return_value = "9001"
return fut
with patch("agent.async_utils.safe_schedule_threadsafe", side_effect=_run_now):
tid = _open_continuable_cron_thread(
{"id": "j1", "name": "Brief"}, adapter, "123", loop=MagicMock(),
)
assert tid == "9001"
def test_seed_thread_session_creates_session_and_mirrors(self):
"""Seeding a freshly-opened thread creates the thread-keyed session via
the adapter's live store and appends the brief via mirror_to_session."""
from cron.scheduler import _seed_cron_thread_session
store = MagicMock()
adapter = MagicMock()
adapter._session_store = store
with patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock:
_seed_cron_thread_session(
{"id": "j1"}, adapter, "telegram", "123", "9001",
"Daily brief Task #2", chat_name="Ops",
)
# Session row created for the thread, then brief mirrored into it.
store.get_or_create_session.assert_called_once()
seeded_source = store.get_or_create_session.call_args[0][0]
assert seeded_source.chat_type == "thread"
assert seeded_source.thread_id == "9001"
mirror_mock.assert_called_once()
assert mirror_mock.call_args.kwargs.get("thread_id") == "9001"
class TestCronContinuableSurfaceInChannel:
"""cron_continuable_surface: in_channel — deliver a continuable cron FLAT
into a channel (no dedicated thread), so a plain channel reply continues the
job via the shared-channel session (platform, chat_id, None).
Design: decisions.md D1/D2/D6 + F5. The scheduler reads the per-platform key
generically from pconfig.extra; the in_channel branch is gated on the
adapter capability flag ``supports_inchannel_continuable`` (Slack=True,
others fail SAFE to thread). In in_channel mode the thread-open branch is
SKIPPED (thread_id stays None), then ``_seed_cron_channel_session`` CREATES
the flat shared-channel session and mirrors the brief into it (the shipped
mirror only APPENDS to an existing session, and the flat channel row is
otherwise absent for a chat_postMessage delivery).
"""
def _slack_cfg(self, extra):
"""A mock GatewayConfig with a Slack pconfig carrying ``extra``."""
from gateway.config import Platform
pconfig = MagicMock()
pconfig.enabled = True
pconfig.extra = extra
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.SLACK: pconfig}
return mock_cfg
def _run_inchannel_delivery(self, extra, adapter, *, mirror_ok=True, origin=None):
"""Drive _deliver_result down the live-adapter path for a Slack
channel-origin job with the given ``extra`` config. Returns the
_open_continuable_cron_thread mock and the mirror_to_session mock."""
from gateway.config import Platform
from concurrent.futures import Future
mock_cfg = self._slack_cfg(extra)
loop = MagicMock()
loop.is_running.return_value = True
def fake_run_coro(coro, _loop):
future = Future()
try:
import asyncio as _asyncio
future.set_result(_asyncio.run(coro))
except BaseException as _e: # noqa: BLE001
future.set_exception(_e)
return future
job = {
"id": "brief-job",
"name": "Daily Brief",
"deliver": "origin",
# Channel origin: no thread_id (flat channel message scheduled it).
# Carries the scheduling user's id — the in_channel seed must key
# the flat channel session to THIS user (see build_session_key).
"origin": origin or {"platform": "slack", "chat_id": "C123", "user_id": "U_HUMAN"},
# Opt into the continuable mirror.
"attach_to_session": True,
}
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
patch("cron.scheduler._open_continuable_cron_thread") as open_thread_mock, \
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro), \
patch("gateway.mirror.mirror_to_session", return_value=mirror_ok) as mirror_mock:
_deliver_result(
job, "Here is today's brief.",
adapters={Platform.SLACK: adapter}, loop=loop,
)
return open_thread_mock, mirror_mock
def _slack_adapter(self, supports_inchannel=True, with_store=True):
adapter = AsyncMock()
adapter.send.return_value = MagicMock(success=True)
# Capability flag read via getattr in the scheduler.
adapter.supports_inchannel_continuable = supports_inchannel
# A live session store so the in_channel seed can CREATE the flat row
# (the real bug: without a create step the mirror no-ops on a missing
# session and the brief is lost). Use a plain MagicMock store.
if with_store:
adapter._session_store = MagicMock()
return adapter
def test_in_channel_skips_thread_open(self):
"""G2: in_channel mode must NOT open a handoff thread."""
adapter = self._slack_adapter(supports_inchannel=True)
open_thread_mock, _ = self._run_inchannel_delivery(
{"cron_continuable_surface": "in_channel"}, adapter,
)
open_thread_mock.assert_not_called()
# --- _seed_cron_channel_session: the create-then-mirror unit + the
# KEY-MATCH invariant (seed key must equal the inbound reply's key) ---
def test_seed_channel_session_key_matches_inbound_channel_reply(self):
"""The whole point: the flat session the seed CREATES must be keyed
identically to what a plain inbound channel reply resolves to. Assert
the invariant directly via build_session_key, not just call args."""
from cron.scheduler import _seed_cron_channel_session
from gateway.session import build_session_key, SessionSource
from gateway.config import Platform
store = MagicMock()
adapter = MagicMock()
adapter._session_store = store
with patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock:
ok = _seed_cron_channel_session(
{"id": "j1", "name": "Brief"}, adapter, "slack", "C123",
"Daily brief", is_dm=False, user_id="U_HUMAN", chat_name="ops",
)
assert ok is True
seeded_source = store.get_or_create_session.call_args[0][0]
seed_key = build_session_key(seeded_source)
# What a plain top-level channel reply (reply_in_thread:false → thread
# None) from the same user resolves to:
inbound = SessionSource(
platform=Platform.SLACK, chat_id="C123", chat_type="group",
user_id="U_HUMAN", thread_id=None,
)
assert seed_key == build_session_key(inbound), (
f"seed key {seed_key} != inbound reply key {build_session_key(inbound)} "
"— the reply would NOT continue the seeded session"
)
mirror_mock.assert_called_once()
assert mirror_mock.call_args.kwargs.get("thread_id") is None
assert mirror_mock.call_args.kwargs.get("user_id") == "U_HUMAN"
class TestMultiTargetDeliveryContinuesOnFailure:
"""When delivery to one target fails inside the standalone thread-pool
fallback, the loop must continue to the remaining targets (#47163).
The fallback runs inside the `except RuntimeError` block of
`_deliver_result`. Before the fix, an exception raised there (SMTP
ConnectionError, future.result timeout) escaped the function entirely —
it is NOT caught by the sibling `except Exception` — crashing the loop
and silently dropping every subsequent target.
"""
def _email_cfg(self):
from gateway.config import Platform
pconfig = MagicMock()
pconfig.enabled = True
mock_cfg = MagicMock()
mock_cfg.platforms = {Platform.EMAIL: pconfig}
return mock_cfg
def test_first_target_failure_does_not_crash_loop(self):
"""First email target fails in the fallback; the second is still attempted."""
job = {
"id": "multi-email-job",
"deliver": "email:a@example.com,email:b@example.com",
}
with patch("gateway.config.load_gateway_config", return_value=self._email_cfg()), \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
patch("asyncio.run", side_effect=RuntimeError("no running loop")), \
patch("concurrent.futures.ThreadPoolExecutor") as mock_pool_cls:
mock_pool = MagicMock()
mock_pool_cls.return_value = mock_pool
fail_future = MagicMock()
fail_future.result.side_effect = ConnectionError("SMTP connection refused")
ok_future = MagicMock()
ok_future.result.return_value = {"success": True}
mock_pool.submit.side_effect = [fail_future, ok_future]
result = _deliver_result(job, "Report content")
# Both targets attempted — the loop did not crash after the first failure.
assert mock_pool.submit.call_count == 2, (
f"expected 2 delivery attempts, got {mock_pool.submit.call_count}"
)
# First target's failure is surfaced in the returned error string.
assert result is not None
assert "a@example.com" in result
assert "SMTP connection refused" in result
def test_all_targets_fail_returns_combined_errors(self):
"""When every target fails, the result reports all of them."""
job = {
"id": "all-fail-job",
"deliver": "email:a@example.com,email:b@example.com",
}
with patch("gateway.config.load_gateway_config", return_value=self._email_cfg()), \
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
patch("asyncio.run", side_effect=RuntimeError("no running loop")), \
patch("concurrent.futures.ThreadPoolExecutor") as mock_pool_cls:
mock_pool = MagicMock()
mock_pool_cls.return_value = mock_pool
fail_future = MagicMock()
fail_future.result.side_effect = ConnectionError("connection refused")
mock_pool.submit.return_value = fail_future
result = _deliver_result(job, "Report content")
assert result is not None
assert "a@example.com" in result
assert "b@example.com" in result
assert mock_pool.submit.call_count == 2
class TestSetCronSessionTitle:
"""Robust cron session titling: #50535/#50536/#50537."""
def test_dedupes_on_duplicate_title(self):
# First write collides (ValueError); helper falls back to lineage #N.
from cron.scheduler import _set_cron_session_title
db = MagicMock()
db.set_session_title.side_effect = [ValueError("in use"), True]
db.get_next_title_in_lineage.return_value = "Nightly Synthesis #2"
out = _set_cron_session_title(db, "sess-1", "Nightly Synthesis")
assert out == "Nightly Synthesis #2"
db.get_next_title_in_lineage.assert_called_once_with("Nightly Synthesis")