Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

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"