Files
EvoScientist/tests/test_schedule_command.py
Xi Zhang 7ccfe68f3f feat: add scheduler functionality with cron-style task management (#306)
* feat: add scheduler functionality with cron-style task management

- Implemented a new scheduler subagent to automate recurring tasks using cron expressions.
- Enhanced the subagent factory to include the skill manager and auxiliary chat model for the scheduler.
- Created a YAML configuration for the scheduler with a detailed system prompt and toolset.
- Updated README files to include documentation on scheduled tasks and usage examples.
- Added tests for the scheduler, including command execution, scheduling tools, and middleware integration.
- Introduced new dependencies for timezone handling and ensured compatibility in the project configuration.

* fix(async-notifier): ensure fallback hint is used for unknown notification kinds

* feat: enhance scheduling functionality and improve system message handling
2026-06-25 17:31:23 +01:00

237 lines
8.7 KiB
Python

"""Tests for the /schedule command."""
from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx():
from EvoScientist.commands.base import CommandContext
ui = MagicMock()
return CommandContext(agent=None, thread_id="tid", ui=ui), ui
def test_list_when_backend_down():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
with patch("EvoScientist.cron.schedule.is_available", return_value=False):
_run(ScheduleCommand().execute(ctx, ["list"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("unavailable" in m.lower() for m in msgs)
def test_add_parses_five_field_cron_and_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch(
"EvoScientist.cron.schedule.create_schedule",
return_value={"cron_id": "c-9"},
) as mk,
):
_run(
ScheduleCommand().execute(
ctx, ["add", "*/10", "*", "*", "*", "*", "search", "uk", "weather"]
)
)
kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather"
def test_list_renders_table():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
rows = [
{
"cron_id": "c-1",
"schedule": "0 9 * * *",
"enabled": True,
"next_run_date": "2026-06-25T09:00:00+00:00",
"metadata": {"name": "daily"},
}
]
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
):
_run(ScheduleCommand().execute(ctx, ["list"]))
ui.mount_renderable.assert_called_once()
def test_add_parses_quoted_cron_and_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch(
"EvoScientist.cron.schedule.create_schedule",
return_value={"cron_id": "c-9"},
) as mk,
):
_run(
ScheduleCommand().execute(ctx, ["add", "*/10 * * * *", "search uk weather"])
)
kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather"
def test_run_with_matching_prefix_fires_matched_prompt():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
rows = [{"cron_id": "c-12345", "metadata": {"prompt": "do the thing"}}]
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
patch(
"EvoScientist.cron.schedule.run_now",
return_value={"run_id": "r-1"},
) as rn,
):
_run(ScheduleCommand().execute(ctx, ["run", "c-123"]))
rn.assert_called_once_with("do the thing")
def test_run_with_no_match_reports():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=[]),
patch("EvoScientist.cron.schedule.run_now") as rn,
):
_run(ScheduleCommand().execute(ctx, ["run", "nope"]))
rn.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No schedule matching" in m for m in msgs)
def test_pause_resume_set_enabled_with_resolved_id():
from EvoScientist.commands.implementation.schedule import ScheduleCommand
rows = [{"cron_id": "c-abcdef", "metadata": {"name": "t"}}]
for sub, expected in (("pause", False), ("resume", True)):
ctx, _ui = _ctx()
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
patch("EvoScientist.cron.schedule.set_enabled") as se,
):
_run(ScheduleCommand().execute(ctx, [sub, "c-abc"]))
se.assert_called_once_with("c-abcdef", expected)
# ---------------------------------------------------------------------------
# B1: _list error handling when backend dies after is_available()
# ---------------------------------------------------------------------------
def test_list_error_shows_red_message_no_exception():
"""B1: list_schedules raising after is_available() shows a red error, not a traceback."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch(
"EvoScientist.cron.schedule.list_schedules",
side_effect=RuntimeError("backend gone"),
),
):
_run(ScheduleCommand().execute(ctx, ["list"]))
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Error:" in m for m in msgs)
# Verify no exception escaped (test would have raised above otherwise)
# ---------------------------------------------------------------------------
# B2: ambiguous prefix detection in /schedule command
# ---------------------------------------------------------------------------
def test_remove_ambiguous_prefix_aborts_without_deleting():
"""B2: two crons sharing a prefix → ambiguity message, delete NOT called."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
rows = [
{"cron_id": "abc-111", "metadata": {}},
{"cron_id": "abc-222", "metadata": {}},
]
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows),
patch("EvoScientist.cron.schedule.delete_schedule") as mk,
):
_run(ScheduleCommand().execute(ctx, ["remove", "abc"]))
mk.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Multiple" in m for m in msgs)
# ---------------------------------------------------------------------------
# B3: sanitized name from prompt
# ---------------------------------------------------------------------------
def test_remove_backend_error_shows_red_error_not_no_match():
"""FIX 1: list_schedules() crashing in _resolve → red 'Error:' message, not 'No schedule matching'."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx()
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch(
"EvoScientist.cron.schedule.list_schedules",
side_effect=RuntimeError("boom"),
),
patch("EvoScientist.cron.schedule.delete_schedule") as mk,
):
_run(ScheduleCommand().execute(ctx, ["remove", "abc"]))
mk.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Error:" in m for m in msgs), f"Expected red Error: message, got: {msgs}"
assert not any("No schedule matching" in m for m in msgs), (
f"Backend crash must not look like a name miss, got: {msgs}"
)
# The error message must mention unavailable/backend to distinguish from other errors.
assert any("unavailable" in m.lower() or "backend" in m.lower() for m in msgs), (
f"Error message should mention backend/unavailable, got: {msgs}"
)
def test_add_name_sanitized_from_nasty_prompt():
"""B3: prompt with newline / slashes / special chars → clean kebab-case name."""
import re
from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx()
# prompt with newline, slashes, and dots
nasty_prompt = "Search /tmp/foo\nand summarize! Latest.Papers."
with (
patch("EvoScientist.cron.schedule.is_available", return_value=True),
patch(
"EvoScientist.cron.schedule.create_schedule",
return_value={"cron_id": "c-x"},
) as mk,
):
_run(ScheduleCommand().execute(ctx, ["add", "*/5 * * * *", nasty_prompt]))
name = mk.call_args.kwargs["name"]
# Must be non-empty, no spaces, no newlines, no slashes
assert name
assert " " not in name
assert "\n" not in name
assert "/" not in name
# Only safe chars: lowercase alphanumeric and hyphens
assert re.fullmatch(r"[a-z0-9][a-z0-9\-]*", name), f"Unexpected name: {name!r}"