327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""Unit tests for the auto-propose cron handler factory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from opensquilla.gateway.config import MetaSkillAutoProposeConfig
|
|
from opensquilla.scheduler.auto_propose_handler import make_auto_propose_handler
|
|
from opensquilla.scheduler.types import CronJob
|
|
from opensquilla.skills.creator.auto_propose import AutoProposeResult
|
|
|
|
|
|
def _make_job(agent_id: str = "main") -> CronJob:
|
|
return CronJob(
|
|
id=f"auto_propose-{agent_id}",
|
|
name=f"auto_propose:{agent_id}",
|
|
payload={"agent_id": agent_id},
|
|
)
|
|
|
|
|
|
def _make_config(**overrides: object) -> MetaSkillAutoProposeConfig:
|
|
base: dict[str, object] = {
|
|
"enabled": True,
|
|
"window_days": 7,
|
|
"min_freq": 2,
|
|
"top_k": 3,
|
|
}
|
|
base.update(overrides)
|
|
return MetaSkillAutoProposeConfig(**base)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kill_switch_short_circuits_before_orchestrator_build(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_AUTO_PROPOSE_DISABLED", "1")
|
|
|
|
def build(_aid: str) -> object:
|
|
raise AssertionError("orchestrator should not be built when kill switch on")
|
|
|
|
def guard() -> bool:
|
|
raise AssertionError("predicate should not be called when kill switch on")
|
|
|
|
handler = make_auto_propose_handler(
|
|
build_orchestrator=build,
|
|
skill_loader=MagicMock(),
|
|
log_dir=tmp_path / "logs",
|
|
proposals_dir=tmp_path / "proposals",
|
|
config=_make_config(),
|
|
enabled_predicate=guard,
|
|
)
|
|
result = await handler(_make_job())
|
|
assert result.delivery_status == "skipped"
|
|
assert "kill_switch" in result.summary
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_predicate_false_skips_without_running_pipeline(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
def build(_aid: str) -> object:
|
|
raise AssertionError("orchestrator should not be built when predicate is false")
|
|
|
|
handler = make_auto_propose_handler(
|
|
build_orchestrator=build,
|
|
skill_loader=MagicMock(),
|
|
log_dir=tmp_path / "logs",
|
|
proposals_dir=tmp_path / "proposals",
|
|
config=_make_config(enabled=False),
|
|
enabled_predicate=lambda: False,
|
|
)
|
|
result = await handler(_make_job())
|
|
assert result.delivery_status == "skipped"
|
|
assert "disabled" in result.summary
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_happy_path_runs_pipeline_and_summarises(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Mock the auto_propose library so we don't hit a real DAG; just
|
|
verify the handler glues kwargs together and surfaces the summary."""
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_auto_propose(**kwargs: object) -> AutoProposeResult:
|
|
captured.update(kwargs)
|
|
return AutoProposeResult(
|
|
proposals_created=["aaaaaaaa", "bbbbbbbb"],
|
|
skipped=[{"reason": "below_min_freq"}],
|
|
errors=[],
|
|
triggered_by="cron",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"opensquilla.scheduler.auto_propose_handler.auto_propose",
|
|
fake_auto_propose,
|
|
)
|
|
|
|
fake_orch = MagicMock(name="orchestrator")
|
|
fake_loader = MagicMock(name="skill_loader")
|
|
log_dir = tmp_path / "logs"
|
|
proposals_dir = tmp_path / "proposals"
|
|
|
|
handler = make_auto_propose_handler(
|
|
build_orchestrator=lambda _aid: fake_orch,
|
|
skill_loader=fake_loader,
|
|
log_dir=log_dir,
|
|
proposals_dir=proposals_dir,
|
|
config=_make_config(
|
|
window_days=14,
|
|
min_freq=4,
|
|
top_k=7,
|
|
auto_enable=True,
|
|
auto_enable_max_risk="medium",
|
|
),
|
|
enabled_predicate=lambda: True,
|
|
)
|
|
result = await handler(_make_job(agent_id="agent-x"))
|
|
|
|
# HandlerResult shape
|
|
assert result.delivery_status == "delivered"
|
|
assert "proposals=2" in result.summary
|
|
assert "skipped=1" in result.summary
|
|
assert "errors=0" in result.summary
|
|
assert "via=cron" in result.summary
|
|
|
|
# Kwargs threaded into the library function
|
|
assert captured["orchestrator"] is fake_orch
|
|
assert captured["skill_loader"] is fake_loader
|
|
assert captured["log_dir"] == log_dir
|
|
assert captured["proposals_dir"] == proposals_dir
|
|
assert captured["window_days"] == 14
|
|
assert captured["min_freq"] == 4
|
|
assert captured["top_k"] == 7
|
|
assert captured["triggered_by"] == "cron"
|
|
assert captured["auto_enable"] is True
|
|
assert captured["auto_enable_max_risk"] == "medium"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orchestrator_build_failure_returns_failed_result(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
def build(_aid: str) -> object:
|
|
raise RuntimeError("provider not configured")
|
|
|
|
handler = make_auto_propose_handler(
|
|
build_orchestrator=build,
|
|
skill_loader=MagicMock(),
|
|
log_dir=tmp_path / "logs",
|
|
proposals_dir=tmp_path / "proposals",
|
|
config=_make_config(),
|
|
enabled_predicate=lambda: True,
|
|
)
|
|
result = await handler(_make_job())
|
|
assert result.delivery_status == "failed"
|
|
assert "provider not configured" in result.summary
|
|
|
|
|
|
def test_config_defaults_off() -> None:
|
|
"""Sanity: zero-arg construction yields a fully-disabled config —
|
|
matches the user requirement that the feature is off until they
|
|
explicitly opt in via toml."""
|
|
cfg = MetaSkillAutoProposeConfig()
|
|
assert cfg.enabled is False
|
|
assert cfg.on_dream_complete is False
|
|
assert cfg.auto_enable is False
|
|
assert cfg.auto_enable_max_risk == "low"
|
|
assert cfg.cron == "0 5 * * *"
|
|
assert cfg.min_freq == 3
|
|
assert cfg.window_days == 30
|
|
assert cfg.top_k == 5
|
|
assert cfg.agent_ids == []
|
|
|
|
|
|
def test_config_validation_rejects_zero_min_freq() -> None:
|
|
from pydantic import ValidationError
|
|
|
|
with pytest.raises(ValidationError):
|
|
MetaSkillAutoProposeConfig(min_freq=0)
|
|
|
|
|
|
def test_config_validation_rejects_negative_window() -> None:
|
|
from pydantic import ValidationError
|
|
|
|
with pytest.raises(ValidationError):
|
|
MetaSkillAutoProposeConfig(window_days=0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_auto_propose_crons_resumes_a_paused_job(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Reg-on the WebUI toggle false→true edge must un-pause a previously
|
|
paused job. ``pause()`` sets ``status=PAUSED`` and ``resume()``
|
|
flips it back; ``update_job(enabled=True)`` alone is insufficient
|
|
because ``enabled`` and ``status`` are independent fields.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from opensquilla.gateway.boot import _register_auto_propose_crons
|
|
from opensquilla.scheduler.types import JobStatus, SessionTarget
|
|
|
|
paused_job = MagicMock()
|
|
paused_job.id = "job-id-1"
|
|
paused_job.name = "auto_propose:main"
|
|
paused_job.schedule_raw = "*/5 * * * *"
|
|
paused_job.payload = {"agent_id": "main"}
|
|
paused_job.session_target = SessionTarget.ISOLATED
|
|
paused_job.status = JobStatus.PAUSED
|
|
|
|
scheduler = MagicMock()
|
|
scheduler.list_jobs = MagicMock(return_value=[paused_job])
|
|
scheduler.update_job = AsyncMock()
|
|
scheduler.resume_job = AsyncMock()
|
|
scheduler.add_job = AsyncMock()
|
|
|
|
auto_cfg = _make_config(cron="*/5 * * * *")
|
|
await _register_auto_propose_crons(
|
|
scheduler=scheduler, auto_cfg=auto_cfg, agent_ids=["main"],
|
|
)
|
|
scheduler.resume_job.assert_called_once_with("job-id-1")
|
|
# No new job added since one already exists.
|
|
scheduler.add_job.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_auto_propose_crons_uses_structured_schedule_for_new_jobs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from opensquilla.gateway.boot import _register_auto_propose_crons
|
|
from opensquilla.scheduler.types import ScheduleKind, SessionTarget
|
|
|
|
scheduler = MagicMock()
|
|
scheduler.list_jobs = MagicMock(return_value=[])
|
|
scheduler.update_job = AsyncMock()
|
|
scheduler.resume_job = AsyncMock()
|
|
scheduler.add_job = AsyncMock()
|
|
|
|
auto_cfg = _make_config(cron="*/5 * * * *")
|
|
await _register_auto_propose_crons(
|
|
scheduler=scheduler, auto_cfg=auto_cfg, agent_ids=["main"],
|
|
)
|
|
|
|
scheduler.add_job.assert_called_once_with(
|
|
name="auto_propose:main",
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_value="*/5 * * * *",
|
|
handler_key="auto_propose",
|
|
payload={"agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_auto_propose_crons_updates_schedule_with_structured_patch(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from opensquilla.gateway.boot import _register_auto_propose_crons
|
|
from opensquilla.scheduler.types import JobStatus, ScheduleKind, SessionTarget
|
|
|
|
existing = MagicMock()
|
|
existing.id = "job-id-3"
|
|
existing.name = "auto_propose:main"
|
|
existing.schedule_raw = "0 5 * * *"
|
|
existing.payload = {"agent_id": "main"}
|
|
existing.session_target = SessionTarget.ISOLATED
|
|
existing.status = JobStatus.PENDING
|
|
|
|
scheduler = MagicMock()
|
|
scheduler.list_jobs = MagicMock(return_value=[existing])
|
|
scheduler.update_job = AsyncMock()
|
|
scheduler.resume_job = AsyncMock()
|
|
scheduler.add_job = AsyncMock()
|
|
|
|
auto_cfg = _make_config(cron="*/5 * * * *")
|
|
await _register_auto_propose_crons(
|
|
scheduler=scheduler, auto_cfg=auto_cfg, agent_ids=["main"],
|
|
)
|
|
|
|
scheduler.update_job.assert_called_once_with(
|
|
"job-id-3",
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_value="*/5 * * * *",
|
|
)
|
|
scheduler.add_job.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_auto_propose_crons_does_not_resume_active_jobs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""An already-running job stays running — no spurious resume_job
|
|
call (idempotent re-register at boot)."""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from opensquilla.gateway.boot import _register_auto_propose_crons
|
|
from opensquilla.scheduler.types import JobStatus, SessionTarget
|
|
|
|
active_job = MagicMock()
|
|
active_job.id = "job-id-2"
|
|
active_job.name = "auto_propose:main"
|
|
active_job.schedule_raw = "*/5 * * * *"
|
|
active_job.payload = {"agent_id": "main"}
|
|
active_job.session_target = SessionTarget.ISOLATED
|
|
active_job.status = JobStatus.PENDING
|
|
|
|
scheduler = MagicMock()
|
|
scheduler.list_jobs = MagicMock(return_value=[active_job])
|
|
scheduler.update_job = AsyncMock()
|
|
scheduler.resume_job = AsyncMock()
|
|
scheduler.add_job = AsyncMock()
|
|
|
|
auto_cfg = _make_config(cron="*/5 * * * *")
|
|
await _register_auto_propose_crons(
|
|
scheduler=scheduler, auto_cfg=auto_cfg, agent_ids=["main"],
|
|
)
|
|
scheduler.resume_job.assert_not_called()
|
|
scheduler.add_job.assert_not_called()
|