Files
2026-07-13 13:12:33 +08:00

291 lines
9.3 KiB
Python

"""Admin cron tool: strict structured-schedule contract.
Covers structured success path plus the four key field-named ``ToolError``
messages (flat string rejection, invalid cron expr, naive ISO ``at``, and
``every_seconds`` lower-bound).
"""
from __future__ import annotations
import json
from typing import Any
import pytest
import opensquilla.tools.builtin.admin as admin_mod
from opensquilla.scheduler.types import SessionTarget
from opensquilla.tools.builtin.admin import cron as cron_tool
from opensquilla.tools.registry import get_default_registry
from opensquilla.tools.types import ToolContext, ToolError, current_tool_context
class _ToolFakeScheduler:
def __init__(self) -> None:
self.added_kwargs: dict[str, Any] | None = None
async def add_job(self, **kwargs):
self.added_kwargs = kwargs
from types import SimpleNamespace
return SimpleNamespace(
id="job-strict",
delivery=SimpleNamespace(ws_topic=""),
)
async def update_job(self, *_, **__):
return None
@pytest.mark.asyncio
async def test_admin_cron_accepts_structured_cron_schedule() -> None:
fake = _ToolFakeScheduler()
admin_mod.set_scheduler(fake)
try:
raw = await cron_tool(
action="add",
schedule={"kind": "cron", "expr": "*/5 * * * *"},
task="ping",
job_kind="agent_turn",
session_target="isolated",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]
assert fake.added_kwargs is not None
assert fake.added_kwargs["schedule_value"] == "*/5 * * * *"
assert fake.added_kwargs["creator_is_owner"] is True
assert json.loads(raw)["schedule_value"] == "*/5 * * * *"
@pytest.mark.asyncio
async def test_admin_cron_records_non_owner_creator_boundary() -> None:
fake = _ToolFakeScheduler()
admin_mod.set_scheduler(fake)
token = current_tool_context.set(
ToolContext(
is_owner=False,
session_key="agent:main:channel:user",
sender_id="channel-user",
channel_kind="feishu",
channel_id="chat-1",
)
)
try:
await cron_tool(
action="add",
schedule={"kind": "cron", "expr": "*/5 * * * *"},
task="ping",
job_kind="agent_turn",
session_target="isolated",
)
finally:
current_tool_context.reset(token)
admin_mod.set_scheduler(None) # type: ignore[arg-type]
assert fake.added_kwargs is not None
assert fake.added_kwargs["creator_is_owner"] is False
@pytest.mark.asyncio
async def test_admin_cron_current_target_binds_caller_session() -> None:
fake = _ToolFakeScheduler()
admin_mod.set_scheduler(fake)
token = current_tool_context.set(
ToolContext(
is_owner=True,
session_key="agent:main:webchat:abc123",
sender_id="owner",
)
)
try:
raw = await cron_tool(
action="add",
schedule={"kind": "cron", "expr": "*/5 * * * *"},
task="ping",
job_kind="agent_turn",
session_target="current",
)
finally:
current_tool_context.reset(token)
admin_mod.set_scheduler(None) # type: ignore[arg-type]
assert fake.added_kwargs is not None
assert fake.added_kwargs["session_target"] == SessionTarget.CURRENT
assert fake.added_kwargs["session_key"] == "agent:main:webchat:abc123"
assert fake.added_kwargs["origin_session_key"] == "agent:main:webchat:abc123"
assert json.loads(raw)["session_target"] == "current"
@pytest.mark.asyncio
async def test_admin_cron_non_owner_current_target_stays_caller_scoped() -> None:
fake = _ToolFakeScheduler()
admin_mod.set_scheduler(fake)
token = current_tool_context.set(
ToolContext(
is_owner=False,
session_key="agent:main:channel:user",
sender_id="channel-user",
channel_kind="feishu",
channel_id="chat-1",
)
)
try:
await cron_tool(
action="add",
schedule={"kind": "cron", "expr": "*/5 * * * *"},
task="ping",
job_kind="agent_turn",
session_target="current",
)
finally:
current_tool_context.reset(token)
admin_mod.set_scheduler(None) # type: ignore[arg-type]
assert fake.added_kwargs is not None
assert fake.added_kwargs["creator_is_owner"] is False
assert fake.added_kwargs["session_target"] == SessionTarget.CURRENT
assert fake.added_kwargs["session_key"] == "agent:main:channel:user"
assert fake.added_kwargs["origin_session_key"] == "agent:main:channel:user"
@pytest.mark.asyncio
async def test_admin_cron_applies_top_level_tz_to_structured_cron() -> None:
fake = _ToolFakeScheduler()
admin_mod.set_scheduler(fake)
try:
await cron_tool(
action="add",
schedule={"kind": "cron", "expr": "0 9 * * *"},
task="ping",
job_kind="agent_turn",
session_target="isolated",
tz="Asia/Shanghai",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]
assert fake.added_kwargs is not None
assert fake.added_kwargs["schedule_tz"] == "Asia/Shanghai"
@pytest.mark.asyncio
async def test_admin_cron_rejects_conflicting_schedule_and_top_level_tz() -> None:
admin_mod.set_scheduler(_ToolFakeScheduler())
try:
with pytest.raises(ToolError, match="schedule.tz conflicts with tz"):
await cron_tool(
action="add",
schedule={
"kind": "cron",
"expr": "0 9 * * *",
"tz": "Asia/Shanghai",
},
task="ping",
job_kind="agent_turn",
session_target="isolated",
tz="America/Los_Angeles",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_admin_cron_rejects_flat_string_schedule() -> None:
"""A bare 5-field cron string must NOT be accepted by the LLM tool."""
admin_mod.set_scheduler(_ToolFakeScheduler())
try:
with pytest.raises(ToolError, match="schedule must be an object"):
await cron_tool(
action="add",
schedule="每5分钟", # type: ignore[arg-type]
task="ping",
job_kind="system_event",
session_target="main",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_admin_cron_rejects_invalid_cron_expr() -> None:
admin_mod.set_scheduler(_ToolFakeScheduler())
try:
with pytest.raises(ToolError, match="schedule.expr invalid"):
await cron_tool(
action="add",
schedule={"kind": "cron", "expr": "not-a-cron"},
task="ping",
job_kind="agent_turn",
session_target="isolated",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_admin_cron_rejects_naive_at_timestamp() -> None:
admin_mod.set_scheduler(_ToolFakeScheduler())
try:
with pytest.raises(ToolError, match="must include a timezone"):
await cron_tool(
action="add",
schedule={"kind": "at", "at": "2026-05-15T09:00:00"},
task="ping",
job_kind="agent_turn",
session_target="isolated",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_admin_cron_rejects_zero_every_seconds() -> None:
admin_mod.set_scheduler(_ToolFakeScheduler())
try:
with pytest.raises(ToolError, match="every_seconds"):
await cron_tool(
action="add",
schedule={"kind": "every", "every_seconds": 0},
task="ping",
job_kind="agent_turn",
session_target="isolated",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]
def test_admin_cron_schema_does_not_advertise_every_anchor() -> None:
registered = get_default_registry().get("cron")
assert registered is not None
schedule_props = registered.spec.parameters["schedule"]["properties"]
assert "anchor_at" not in schedule_props
def test_admin_cron_schema_advertises_current_session_target() -> None:
registered = get_default_registry().get("cron")
assert registered is not None
targets = registered.spec.parameters["session_target"]["enum"]
assert "current" in targets
@pytest.mark.asyncio
async def test_admin_cron_rejects_every_anchor_until_supported() -> None:
admin_mod.set_scheduler(_ToolFakeScheduler())
try:
with pytest.raises(ToolError, match="schedule.anchor_at is not supported"):
await cron_tool(
action="add",
schedule={
"kind": "every",
"every_seconds": 300,
"anchor_at": "2026-05-18T09:00:00+08:00",
},
task="ping",
job_kind="agent_turn",
session_target="isolated",
)
finally:
admin_mod.set_scheduler(None) # type: ignore[arg-type]