fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
443 lines
16 KiB
Python
443 lines
16 KiB
Python
"""Tests for the SchedulerTool."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
|
|
# Pre-import to stabilise the ToolManager.load_tools walk's import order
|
|
# (avoids the mcp_tool ↔ application.api.user circular when ToolManager
|
|
# instantiation is the first reachable importer in a test process).
|
|
import application.api.user.tools.mcp # noqa: F401
|
|
|
|
from application.agents.tools.scheduler import SchedulerTool # noqa: E402
|
|
from application.core.settings import settings # noqa: E402
|
|
from application.storage.db.repositories.schedules import SchedulesRepository # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def patch_sessions(pg_conn):
|
|
from contextlib import contextmanager
|
|
|
|
@contextmanager
|
|
def _ctx():
|
|
yield pg_conn
|
|
|
|
with patch(
|
|
"application.agents.tools.scheduler.db_session", _ctx,
|
|
), patch(
|
|
"application.agents.tools.scheduler.db_readonly", _ctx,
|
|
):
|
|
yield
|
|
|
|
|
|
def _make_agent(conn, user_id: str = "u1") -> str:
|
|
row = conn.execute(
|
|
text(
|
|
"INSERT INTO agents (user_id, name, status) "
|
|
"VALUES (:u, 'a', 'draft') RETURNING id"
|
|
),
|
|
{"u": user_id},
|
|
).fetchone()
|
|
return str(row[0])
|
|
|
|
|
|
def _make_tool(name="scheduler", *, user_id="u1", agent_id=None, conversation_id=None):
|
|
return SchedulerTool(
|
|
tool_config={
|
|
"agent_id": agent_id,
|
|
"conversation_id": conversation_id,
|
|
},
|
|
user_id=user_id,
|
|
)
|
|
|
|
|
|
class TestGuards:
|
|
def test_requires_user_id(self):
|
|
tool = SchedulerTool(tool_config={"agent_id": str(uuid.uuid4())})
|
|
assert "user_id" in tool.execute_action("schedule_task", instruction="x")
|
|
|
|
def test_rejects_invalid_agent_id(self):
|
|
tool = _make_tool(user_id="u1", agent_id="not-a-uuid")
|
|
assert "invalid agent_id" in tool.execute_action(
|
|
"schedule_task", instruction="x"
|
|
)
|
|
|
|
def test_requires_agent_or_conversation(self):
|
|
# Neither agent_id nor conversation_id → hard error (webhook caller
|
|
# outside any chat); scheduler can't operate without a conversation home.
|
|
tool = _make_tool(user_id="u1", agent_id=None, conversation_id=None)
|
|
out = tool.execute_action("schedule_task", instruction="x")
|
|
assert "conversation_id" in out or "conversation home" in out
|
|
|
|
|
|
class TestScheduleTask:
|
|
def test_creates_with_delay(self, pg_conn, patch_sessions):
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id, conversation_id=None)
|
|
out = tool.execute_action(
|
|
"schedule_task", instruction="say hi", delay="2h",
|
|
)
|
|
parsed = json.loads(out)
|
|
assert "task_id" in parsed
|
|
assert "resolved_run_at" in parsed
|
|
row = SchedulesRepository(pg_conn).get(parsed["task_id"], "u1")
|
|
assert row is not None
|
|
assert row["trigger_type"] == "once"
|
|
assert row["created_via"] == "chat"
|
|
fire = datetime.fromisoformat(parsed["resolved_run_at"].replace("Z", "+00:00"))
|
|
delta = fire - datetime.now(timezone.utc)
|
|
assert timedelta(minutes=119) <= delta <= timedelta(minutes=121)
|
|
|
|
def test_creates_with_run_at(self, pg_conn, patch_sessions):
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
fire = (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat()
|
|
out = tool.execute_action(
|
|
"schedule_task", instruction="x", run_at=fire,
|
|
)
|
|
parsed = json.loads(out)
|
|
assert "task_id" in parsed
|
|
|
|
def test_rejects_both_delay_and_run_at(self, pg_conn, patch_sessions):
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
out = tool.execute_action(
|
|
"schedule_task", instruction="x", delay="30m",
|
|
run_at="2030-01-01T00:00:00Z",
|
|
)
|
|
assert "only one" in out
|
|
|
|
def test_rejects_past_run_at(self, pg_conn, patch_sessions):
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
out = tool.execute_action("schedule_task", instruction="x", run_at=past)
|
|
assert "past" in out
|
|
|
|
def test_rejects_beyond_horizon(
|
|
self, pg_conn, patch_sessions, monkeypatch
|
|
):
|
|
monkeypatch.setattr(settings, "SCHEDULE_ONCE_MAX_HORIZON", 3600)
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
far = (datetime.now(timezone.utc) + timedelta(hours=10)).isoformat()
|
|
out = tool.execute_action("schedule_task", instruction="x", run_at=far)
|
|
assert "horizon" in out
|
|
|
|
|
|
class TestQuota:
|
|
def test_quota_enforced(self, pg_conn, patch_sessions, monkeypatch):
|
|
monkeypatch.setattr(settings, "SCHEDULE_MAX_PER_USER", 2)
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
for _ in range(2):
|
|
out = tool.execute_action(
|
|
"schedule_task", instruction="x", delay="1h",
|
|
)
|
|
assert "task_id" in out
|
|
out = tool.execute_action(
|
|
"schedule_task", instruction="x", delay="1h",
|
|
)
|
|
assert "maximum" in out
|
|
|
|
|
|
class TestListAndCancel:
|
|
def test_list_returns_pending(self, pg_conn, patch_sessions):
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
for _ in range(3):
|
|
tool.execute_action(
|
|
"schedule_task", instruction="x", delay="1h",
|
|
)
|
|
listed = json.loads(tool.execute_action("list_scheduled_tasks"))
|
|
assert len(listed["tasks"]) == 3
|
|
assert all(t["status"] == "active" for t in listed["tasks"])
|
|
|
|
def test_cancel_flips_status(self, pg_conn, patch_sessions):
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
created = json.loads(
|
|
tool.execute_action("schedule_task", instruction="x", delay="1h")
|
|
)
|
|
out = tool.execute_action(
|
|
"cancel_scheduled_task", task_id=created["task_id"]
|
|
)
|
|
assert "cancelled" in out
|
|
row = SchedulesRepository(pg_conn).get(created["task_id"], "u1")
|
|
assert row["status"] == "cancelled"
|
|
|
|
def test_cancel_unknown_id_rejected(self, pg_conn, patch_sessions):
|
|
agent_id = _make_agent(pg_conn)
|
|
tool = _make_tool(user_id="u1", agent_id=agent_id)
|
|
out = tool.execute_action(
|
|
"cancel_scheduled_task", task_id="not-a-uuid",
|
|
)
|
|
assert "valid id" in out
|
|
|
|
|
|
class TestActionsMetadata:
|
|
def test_actions_listed(self):
|
|
tool = SchedulerTool()
|
|
names = {a["name"] for a in tool.get_actions_metadata()}
|
|
assert names == {
|
|
"schedule_task", "list_scheduled_tasks", "cancel_scheduled_task",
|
|
}
|
|
|
|
|
|
class TestAgentlessInvocation:
|
|
def test_agentless_creates_schedule_with_null_agent_id(
|
|
self, pg_conn, patch_sessions,
|
|
):
|
|
"""Agentless chat → scheduler.schedule_task → row with NULL agent_id."""
|
|
conv_id = pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'origin') RETURNING id"
|
|
)
|
|
).fetchone()[0]
|
|
tool = _make_tool(
|
|
user_id="u1", agent_id=None, conversation_id=str(conv_id),
|
|
)
|
|
out = tool.execute_action(
|
|
"schedule_task", instruction="ping me later", delay="1h",
|
|
)
|
|
parsed = json.loads(out)
|
|
assert "task_id" in parsed
|
|
row = SchedulesRepository(pg_conn).get(parsed["task_id"], "u1")
|
|
assert row is not None
|
|
assert row["agent_id"] is None
|
|
assert row["trigger_type"] == "once"
|
|
assert row["created_via"] == "chat"
|
|
assert str(row["origin_conversation_id"]) == str(conv_id)
|
|
|
|
def test_agentless_list_scoped_to_conversation(
|
|
self, pg_conn, patch_sessions,
|
|
):
|
|
"""Agentless list_scheduled_tasks scopes to user + origin conversation."""
|
|
conv_a = pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'a') RETURNING id"
|
|
)
|
|
).fetchone()[0]
|
|
conv_b = pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'b') RETURNING id"
|
|
)
|
|
).fetchone()[0]
|
|
tool_a = _make_tool(
|
|
user_id="u1", agent_id=None, conversation_id=str(conv_a),
|
|
)
|
|
tool_b = _make_tool(
|
|
user_id="u1", agent_id=None, conversation_id=str(conv_b),
|
|
)
|
|
tool_a.execute_action(
|
|
"schedule_task", instruction="in-a", delay="1h",
|
|
)
|
|
tool_a.execute_action(
|
|
"schedule_task", instruction="in-a-2", delay="2h",
|
|
)
|
|
tool_b.execute_action(
|
|
"schedule_task", instruction="in-b", delay="3h",
|
|
)
|
|
listed_a = json.loads(tool_a.execute_action("list_scheduled_tasks"))
|
|
listed_b = json.loads(tool_b.execute_action("list_scheduled_tasks"))
|
|
assert len(listed_a["tasks"]) == 2
|
|
assert len(listed_b["tasks"]) == 1
|
|
assert all(t["status"] == "active" for t in listed_a["tasks"])
|
|
|
|
def test_agentless_cancel_blocked_for_other_conversation(
|
|
self, pg_conn, patch_sessions,
|
|
):
|
|
"""A user can't cancel tasks created in another agentless chat."""
|
|
conv_a = pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'a') RETURNING id"
|
|
)
|
|
).fetchone()[0]
|
|
conv_b = pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'b') RETURNING id"
|
|
)
|
|
).fetchone()[0]
|
|
tool_a = _make_tool(
|
|
user_id="u1", agent_id=None, conversation_id=str(conv_a),
|
|
)
|
|
tool_b = _make_tool(
|
|
user_id="u1", agent_id=None, conversation_id=str(conv_b),
|
|
)
|
|
created = json.loads(
|
|
tool_a.execute_action(
|
|
"schedule_task", instruction="x", delay="1h",
|
|
)
|
|
)
|
|
out = tool_b.execute_action(
|
|
"cancel_scheduled_task", task_id=created["task_id"],
|
|
)
|
|
assert "not found" in out
|
|
|
|
def test_agentless_cancel_succeeds_in_own_conversation(
|
|
self, pg_conn, patch_sessions,
|
|
):
|
|
conv = pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'a') RETURNING id"
|
|
)
|
|
).fetchone()[0]
|
|
tool = _make_tool(
|
|
user_id="u1", agent_id=None, conversation_id=str(conv),
|
|
)
|
|
created = json.loads(
|
|
tool.execute_action("schedule_task", instruction="x", delay="1h")
|
|
)
|
|
out = tool.execute_action(
|
|
"cancel_scheduled_task", task_id=created["task_id"],
|
|
)
|
|
assert "cancelled" in out
|
|
|
|
def test_agentless_snapshot_allowlist_lists_user_tools(
|
|
self, pg_conn, patch_sessions,
|
|
):
|
|
"""Agentless schedule captures the user's non-approval tools at fire-time."""
|
|
from application.agents.tools.scheduler import _safe_default_allowlist
|
|
from application.storage.db.repositories.user_tools import (
|
|
UserToolsRepository,
|
|
)
|
|
|
|
# Seed an explicit non-approval user tool.
|
|
user_tool = UserToolsRepository(pg_conn).create(
|
|
"u1", "read_webpage", config={}, actions=[
|
|
{"name": "fetch", "active": True, "require_approval": False},
|
|
], status=True,
|
|
)
|
|
conv = pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'a') RETURNING id"
|
|
)
|
|
).fetchone()[0]
|
|
tool = _make_tool(
|
|
user_id="u1", agent_id=None, conversation_id=str(conv),
|
|
)
|
|
out = tool.execute_action(
|
|
"schedule_task", instruction="x", delay="1h",
|
|
)
|
|
parsed = json.loads(out)
|
|
row = SchedulesRepository(pg_conn).get(parsed["task_id"], "u1")
|
|
# The explicit user_tools row is in the snapshot (approval=False).
|
|
assert str(user_tool["id"]) in (row["tool_allowlist"] or [])
|
|
# Direct allowlist call returns the same set.
|
|
ids = _safe_default_allowlist(None, "u1")
|
|
assert str(user_tool["id"]) in ids
|
|
|
|
|
|
class TestAllowlistSnapshotSemantics:
|
|
"""The schedule's ``tool_allowlist`` is a **pre-auth snapshot**, not a
|
|
visibility cap. The LLM sees the user's *current* tools at fire time
|
|
(via ``ToolExecutor._get_user_tools``); the snapshot only governs
|
|
whether an approval-gated tool can run unattended."""
|
|
|
|
def test_tool_added_after_creation_is_visible_at_fire_time(
|
|
self, pg_conn, patch_sessions,
|
|
):
|
|
"""Schedule captures the allowlist at creation; a tool added later is
|
|
visible at fire time (resolver re-queries) but isn't in the snapshot."""
|
|
from application.agents.tools.scheduler import _safe_default_allowlist
|
|
from application.storage.db.repositories.user_tools import (
|
|
UserToolsRepository,
|
|
)
|
|
|
|
pg_conn.execute(
|
|
text(
|
|
"INSERT INTO conversations (user_id, name) "
|
|
"VALUES ('u1', 'snap-add') RETURNING id"
|
|
)
|
|
).fetchone()
|
|
# Snapshot the allowlist BEFORE adding the new tool.
|
|
snapshot_before = _safe_default_allowlist(None, "u1")
|
|
|
|
# User adds an approval-gated tool AFTER schedule creation.
|
|
added = UserToolsRepository(pg_conn).create(
|
|
"u1", "telegram",
|
|
config={}, actions=[
|
|
{"name": "send", "active": True, "require_approval": True},
|
|
], status=True,
|
|
)
|
|
|
|
# The snapshot does NOT include the post-creation tool.
|
|
assert str(added["id"]) not in snapshot_before
|
|
# …but the LLM sees it at fire time (current resolver state).
|
|
snapshot_after = _safe_default_allowlist(None, "u1")
|
|
# An approval-gated tool is excluded from the snapshot regardless,
|
|
# but it IS in ``list_active_for_user`` (what the LLM's tool_executor
|
|
# uses) — make that explicit:
|
|
ids_now = {
|
|
str(r["id"]) for r in
|
|
UserToolsRepository(pg_conn).list_active_for_user("u1")
|
|
}
|
|
assert str(added["id"]) in ids_now
|
|
# And approval-gated still skipped from the safe allowlist.
|
|
assert str(added["id"]) not in snapshot_after
|
|
|
|
def test_tool_deleted_between_creation_and_fire_is_invisible(
|
|
self, pg_conn, patch_sessions,
|
|
):
|
|
"""A tool deleted between schedule creation and fire is gone for the
|
|
LLM at fire time (the resolver lists the current state)."""
|
|
from application.agents.tools.scheduler import _safe_default_allowlist
|
|
from application.storage.db.repositories.user_tools import (
|
|
UserToolsRepository,
|
|
)
|
|
|
|
repo = UserToolsRepository(pg_conn)
|
|
existing = repo.create(
|
|
"u1", "read_webpage",
|
|
config={}, actions=[
|
|
{"name": "fetch", "active": True, "require_approval": False},
|
|
], status=True,
|
|
)
|
|
# Snapshot at creation includes it (non-approval).
|
|
snapshot = _safe_default_allowlist(None, "u1")
|
|
assert str(existing["id"]) in snapshot
|
|
|
|
# User deletes it; fire-time resolver no longer surfaces it.
|
|
repo.delete(str(existing["id"]), "u1")
|
|
ids_now = {r["id"] for r in repo.list_active_for_user("u1")}
|
|
assert str(existing["id"]) not in ids_now
|
|
# And the freshly-recomputed allowlist drops it too.
|
|
snapshot_after = _safe_default_allowlist(None, "u1")
|
|
assert str(existing["id"]) not in snapshot_after
|
|
|
|
|
|
class TestInternalFlag:
|
|
def test_internal_true(self):
|
|
assert SchedulerTool.internal is True
|
|
|
|
def test_not_in_tool_manager_auto_load(self):
|
|
from application.agents.tools.tool_manager import ToolManager
|
|
|
|
tm = ToolManager(config={})
|
|
assert "scheduler" not in tm.tools
|
|
|
|
def test_load_tool_special_case_still_works(self):
|
|
from application.agents.tools.tool_manager import ToolManager
|
|
|
|
tm = ToolManager(config={})
|
|
tool = tm.load_tool(
|
|
"scheduler",
|
|
tool_config={"agent_id": str(uuid.uuid4())},
|
|
user_id="u1",
|
|
)
|
|
assert isinstance(tool, SchedulerTool)
|
|
assert tool.user_id == "u1"
|