open-swe/tests/agent/test_agent_schedules.py
Adam Moussa ae1f883b4c
refactor: move tests into tests/<domain>/ layout
Applies the plan's C5 step: git mv every test per the domain-reorg
move-map (movemap-m50.txt) into tests/{agent,analyzer,auth,dashboard,
github,middleware,models,reviewer,sandbox,slack,tools,webhooks}/, plus
the 13 fork-only placements from the scoping report §2c (Atlassian
webhook tests -> tests/webhooks/, test_atlassian_connect.py and
test_auth_error_leak.py -> tests/auth/, jira/confluence util tests ->
tests/tools/, test_repo_binding_isolation.py -> tests/sandbox/,
bot-identity/autofix tests -> tests/github/).

Path-only move: the only content edits are parents[1] -> parents[2]
fixes in test_e2b_integration.py and test_daytona_integration.py,
required because their __file__-relative ROOT path gained one more
directory level in the move.

Monkeypatch retargets for these files were already completed in C4;
none remained outstanding here.
2026-07-17 14:42:45 -04:00

466 lines
16 KiB
Python

from __future__ import annotations
from typing import Any
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from agent.dashboard import schedules
from agent.dashboard.schedules import ScheduleCreateBody, ScheduleUpdateBody
class _FakeStore:
def __init__(self) -> None:
self.items: dict[tuple[tuple[str, ...], str], dict[str, Any]] = {}
self.deleted: list[tuple[tuple[str, ...], str]] = []
async def get_item(self, namespace: list[str], key: str) -> dict[str, Any] | None:
value = self.items.get((tuple(namespace), key))
return {"value": value} if value is not None else None
async def put_item(self, namespace: list[str], key: str, value: dict[str, Any]) -> None:
self.items[(tuple(namespace), key)] = value
async def delete_item(self, namespace: list[str], key: str) -> None:
self.deleted.append((tuple(namespace), key))
self.items.pop((tuple(namespace), key), None)
async def search_items(
self,
namespace: list[str],
filter: dict[str, Any] | None = None,
limit: int = 1000,
offset: int = 0,
) -> dict[str, Any]:
values = [
value
for (stored_namespace, _), value in self.items.items()
if stored_namespace == tuple(namespace)
]
if filter:
values = [
value
for value in values
if all(value.get(key) == expected for key, expected in filter.items())
]
return {"items": [{"value": value} for value in values[offset : offset + limit]]}
class _FakeCrons:
def __init__(self) -> None:
self.created: list[dict[str, Any]] = []
self.deleted: list[str] = []
async def create(self, assistant_id: str, **kwargs: Any) -> dict[str, Any]:
self.created.append({"assistant_id": assistant_id, **kwargs})
return {"cron_id": f"cron_{len(self.created)}"}
async def delete(self, cron_id: str) -> None:
self.deleted.append(cron_id)
class _FakeThreads:
def __init__(self) -> None:
self.created: list[dict[str, Any]] = []
self.updated: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> None:
self.created.append(kwargs)
async def update(self, **kwargs: Any) -> None:
self.updated.append(kwargs)
class _FakeRuns:
def __init__(self) -> None:
self.created: list[dict[str, Any]] = []
async def create(self, thread_id: str, assistant_id: str, **kwargs: Any) -> dict[str, Any]:
self.created.append({"thread_id": thread_id, "assistant_id": assistant_id, **kwargs})
return {"run_id": "run_123"}
class _FakeClient:
def __init__(self) -> None:
self.store = _FakeStore()
self.crons = _FakeCrons()
self.threads = _FakeThreads()
self.runs = _FakeRuns()
@pytest.fixture
def fake_client(monkeypatch) -> _FakeClient: # noqa: ANN001
client = _FakeClient()
monkeypatch.setattr(schedules, "_client", lambda: client)
return client
@pytest.fixture
def auth(monkeypatch) -> None: # noqa: ANN001
async def fake_get_valid_access_token(login: str) -> str:
return "gho_token"
async def fake_get_profile(login: str) -> dict[str, Any]:
return {"base_branch": "main", "branch_prefix": "open-swe"}
async def fake_resolve_run_email(login: str, profile: dict[str, Any]) -> str:
return "alice@example.com"
async def fake_repo_config_for_user(login: str, full_name: str | None) -> dict[str, str] | None:
if not full_name:
return None
owner, name = full_name.split("/", 1)
return {"owner": owner, "name": name}
async def fake_require_repo_access_for_user(login: str, full_name: str) -> str:
return "gho_token"
monkeypatch.setattr(schedules, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(schedules, "get_profile", fake_get_profile)
monkeypatch.setattr(schedules, "_resolve_run_email", fake_resolve_run_email)
monkeypatch.setattr(schedules, "repo_config_for_user", fake_repo_config_for_user)
monkeypatch.setattr(
schedules, "require_repo_access_for_user", fake_require_repo_access_for_user
)
def test_cron_validation_rejects_non_five_field_expression() -> None:
with pytest.raises(ValidationError):
ScheduleCreateBody(prompt="hello", schedule="0 9 * *")
def test_cron_validation_accepts_steps_ranges_and_lists() -> None:
body = ScheduleCreateBody(prompt="hello", schedule="*/15 9-17 * * 1,3,5")
assert body.schedule == "*/15 9-17 * * 1,3,5"
def test_slack_report_channel_normalizes_and_validates() -> None:
body = ScheduleCreateBody(
prompt="hello", schedule="0 9 * * 1", slack_report_channel=" #C0123ABCD "
)
assert body.slack_report_channel == "C0123ABCD"
blank = ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel=" ")
assert blank.slack_report_channel is None
with pytest.raises(ValidationError):
ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel="not a chan")
with pytest.raises(ValidationError):
ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel="123456")
async def test_create_agent_schedule_persists_slack_report_channel(fake_client, auth) -> None: # noqa: ANN001, ARG001
body = ScheduleCreateBody(
name="Daily report",
prompt="Summarize merged PRs",
schedule="0 9 * * 1-5",
slack_report_channel="C0123ABCD",
)
result = await schedules.create_agent_schedule("alice", body, email="alice@example.com")
assert result["slackReportChannel"] == "C0123ABCD"
stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), result["id"])]
assert stored["slack_report_channel"] == "C0123ABCD"
async def test_update_agent_schedule_clears_slack_report_channel(fake_client) -> None: # noqa: ANN001
record = {
"id": "sched_1",
"name": "Daily",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"effort": None,
"slack_report_channel": "C0123ABCD",
"enabled": True,
"cron_id": "cron_old",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
result = await schedules.update_agent_schedule(
"sched_1",
"alice",
ScheduleUpdateBody(slack_report_channel=""),
email="alice@example.com",
)
assert result["slackReportChannel"] is None
async def test_agent_run_config_seeds_slack_thread_channel() -> None:
record = {
"id": "sched_1",
"model": "Default",
"effort": None,
"created_by": "alice",
"user_email": "alice@example.com",
"slack_report_channel": "C0123ABCD",
}
config = await schedules._agent_run_config(record, "thread_1")
assert config["configurable"]["slack_thread"] == {"channel_id": "C0123ABCD"}
async def test_agent_run_config_omits_slack_thread_without_channel() -> None:
record = {
"id": "sched_1",
"model": "Default",
"effort": None,
"created_by": "alice",
"user_email": "alice@example.com",
"slack_report_channel": None,
}
config = await schedules._agent_run_config(record, "thread_1")
assert "slack_thread" not in config["configurable"]
async def test_create_agent_schedule_registers_scheduler_cron(fake_client, auth) -> None: # noqa: ANN001, ARG001
body = ScheduleCreateBody(
name="Daily report",
prompt="Summarize merged PRs",
schedule="0 9 * * 1-5",
repo="langchain-ai/open-swe",
)
result = await schedules.create_agent_schedule("alice", body, email="alice@example.com")
assert result["name"] == "Daily report"
assert result["enabled"] is True
assert result["cronId"] == "cron_1"
created = fake_client.crons.created[0]
assert created["assistant_id"] == "scheduler"
assert created["schedule"] == "0 9 * * 1-5"
assert created["input"]["schedule_id"] == result["id"]
assert created["config"]["configurable"]["schedule_id"] == result["id"]
assert created["metadata"]["kind"] == "agent_schedule"
async def test_create_agent_schedule_requires_dashboard_token(fake_client, monkeypatch) -> None: # noqa: ANN001, ARG001
async def no_token(login: str) -> None:
return None
monkeypatch.setattr(schedules, "get_valid_access_token", no_token)
with pytest.raises(HTTPException) as exc:
await schedules.create_agent_schedule(
"alice", ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1")
)
assert exc.value.status_code == 401
assert fake_client.crons.created == []
async def test_create_agent_schedule_requires_repo_access(fake_client, auth, monkeypatch) -> None: # noqa: ANN001, ARG001
async def deny_repo(login: str, full_name: str | None) -> dict[str, str] | None:
raise HTTPException(403, "no access to this private repository")
monkeypatch.setattr(schedules, "repo_config_for_user", deny_repo)
with pytest.raises(HTTPException) as exc:
await schedules.create_agent_schedule(
"alice",
ScheduleCreateBody(
prompt="hello",
schedule="0 9 * * 1",
repo="victim/private",
),
)
assert exc.value.status_code == 403
assert fake_client.crons.created == []
async def test_list_agent_schedules_uses_owner_filters_and_paginates(fake_client) -> None: # noqa: ANN001
for i in range(125):
await fake_client.store.put_item(
schedules.SCHEDULES_NAMESPACE,
f"alice_{i}",
{
"id": f"alice_{i}",
"name": f"Alice {i}",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"enabled": True,
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": f"2026-01-01T00:{i % 60:02d}:00+00:00",
},
)
await fake_client.store.put_item(
schedules.SCHEDULES_NAMESPACE,
"bob_1",
{
"id": "bob_1",
"name": "Bob",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"enabled": True,
"created_by": "bob",
"user_email": "bob@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
},
)
result = await schedules.list_agent_schedules("alice", email="alice@example.com")
assert len(result) == 125
assert {item["id"] for item in result} == {f"alice_{i}" for i in range(125)}
async def test_update_agent_schedule_rechecks_repo_access(fake_client, auth, monkeypatch) -> None: # noqa: ANN001, ARG001
record = {
"id": "sched_1",
"name": "Daily",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"effort": None,
"enabled": True,
"cron_id": "cron_old",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
async def repo_config(login: str, full_name: str | None) -> dict[str, str] | None:
assert full_name == "langchain-ai/open-swe"
return {"owner": "langchain-ai", "name": "open-swe"}
monkeypatch.setattr(schedules, "repo_config_for_user", repo_config)
result = await schedules.update_agent_schedule(
"sched_1",
"alice",
ScheduleUpdateBody(repo="langchain-ai/open-swe"),
email="alice@example.com",
)
assert result["repo"] == "langchain-ai/open-swe"
async def test_update_agent_schedule_pause_deletes_cron(fake_client) -> None: # noqa: ANN001
record = {
"id": "sched_1",
"name": "Daily",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"effort": None,
"enabled": True,
"cron_id": "cron_old",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
result = await schedules.update_agent_schedule(
"sched_1", "alice", ScheduleUpdateBody(enabled=False), email="alice@example.com"
)
assert result["enabled"] is False
assert result["cronId"] is None
assert fake_client.crons.deleted == ["cron_old"]
async def test_launch_scheduled_agent_run_skips_when_repo_access_revoked(
fake_client, auth, monkeypatch
) -> None: # noqa: ANN001, ARG001
record = {
"id": "sched_1",
"name": "Weekly dependencies",
"prompt": "Check dependencies and open a PR if needed",
"schedule": "0 9 * * 1",
"repo": {"owner": "langchain-ai", "name": "open-swe"},
"model": "Default",
"effort": None,
"base_branch": "main",
"branch_prefix": "open-swe",
"enabled": True,
"cron_id": "cron_1",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
async def deny_access(login: str, full_name: str) -> str:
raise HTTPException(403, "no access to this private repository")
monkeypatch.setattr(schedules, "require_repo_access_for_user", deny_access)
result = await schedules.launch_scheduled_agent_run("sched_1")
assert result == {
"status": "unauthorized",
"schedule_id": "sched_1",
"error": "no access to this private repository",
}
assert fake_client.runs.created == []
stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), "sched_1")]
assert stored["last_error"] == "no access to this private repository"
async def test_launch_scheduled_agent_run_starts_fresh_agent_thread(fake_client, auth) -> None: # noqa: ANN001, ARG001
record = {
"id": "sched_1",
"name": "Weekly dependencies",
"prompt": "Check dependencies and open a PR if needed",
"schedule": "0 9 * * 1",
"repo": {"owner": "langchain-ai", "name": "open-swe"},
"model": "Default",
"effort": None,
"base_branch": "main",
"branch_prefix": "open-swe",
"enabled": True,
"cron_id": "cron_1",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
result = await schedules.launch_scheduled_agent_run("sched_1")
assert result["status"] == "started"
thread_id = result["thread_id"]
assert fake_client.threads.created[0]["thread_id"] == thread_id
metadata = fake_client.threads.created[0]["metadata"]
assert metadata["source"] == "schedule"
assert metadata["repo_owner"] == "langchain-ai"
assert metadata["repo_name"] == "open-swe"
run = fake_client.runs.created[0]
assert run["thread_id"] == thread_id
assert run["assistant_id"] == "agent"
assert run["input"]["messages"][0]["content"] == record["prompt"]
assert run["config"]["configurable"]["source"] == "schedule"
assert run["config"]["configurable"]["repo"] == record["repo"]
stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), "sched_1")]
assert stored["last_thread_id"] == thread_id
assert stored["last_run_id"] == "run_123"