diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 8bedea5a4..a719a29f3 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -60,7 +60,7 @@ class CronTool(Tool): }, "tz": { "type": "string", - "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')", + "description": "IANA timezone for cron_expr or at (e.g. 'America/Vancouver')", }, "at": { "type": "string", @@ -104,8 +104,8 @@ class CronTool(Tool): return "Error: message is required for add" if not self._channel or not self._chat_id: return "Error: no session context (channel/chat_id)" - if tz and not cron_expr: - return "Error: tz can only be used with cron_expr" + if tz and not cron_expr and not at: + return "Error: tz can only be used with cron_expr or at" if tz: from zoneinfo import ZoneInfo @@ -127,6 +127,8 @@ class CronTool(Tool): dt = datetime.fromisoformat(at) except ValueError: return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" + if tz and dt.tzinfo is None: + dt = dt.replace(tzinfo=ZoneInfo(tz)) at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md index cc3516e03..8598e2977 100644 --- a/nanobot/skills/cron/SKILL.md +++ b/nanobot/skills/cron/SKILL.md @@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time): cron(action="add", message="Remind me about the meeting", at="") ``` +One-time task with timezone (naive datetime interpreted in given tz): +``` +cron(action="add", message="Drink water!", at="2026-03-18T14:40:00", tz="Asia/Shanghai") +``` + Timezone-aware cron: ``` cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver") @@ -51,7 +56,8 @@ cron(action="remove", job_id="abc123") | weekdays at 5pm | cron_expr: "0 17 * * 1-5" | | 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" | | at a specific time | at: ISO datetime string (compute from current time) | +| at 2pm Shanghai time | at: "2026-03-18T14:00:00", tz: "Asia/Shanghai" | ## Timezone -Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used. +Use `tz` with `cron_expr` or `at` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used. diff --git a/tests/test_cron_tool_tz_at.py b/tests/test_cron_tool_tz_at.py new file mode 100644 index 000000000..ba1e42831 --- /dev/null +++ b/tests/test_cron_tool_tz_at.py @@ -0,0 +1,103 @@ +"""Tests for CronTool at+tz timezone handling.""" + +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +import pytest + +from nanobot.agent.tools.cron import CronTool +from nanobot.cron.service import CronService + + +def _make_tool(tmp_path) -> CronTool: + service = CronService(tmp_path / "cron" / "jobs.json") + tool = CronTool(service) + tool.set_context("test-channel", "test-chat") + return tool + + +@pytest.mark.asyncio +async def test_at_with_tz_naive_datetime(tmp_path) -> None: + """Naive datetime + tz should be interpreted in the given timezone.""" + tool = _make_tool(tmp_path) + result = await tool.execute( + action="add", + message="Shanghai reminder", + at="2026-03-18T14:00:00", + tz="Asia/Shanghai", + ) + assert "Created job" in result + + jobs = tool._cron.list_jobs() + assert len(jobs) == 1 + # Asia/Shanghai is UTC+8, so 14:00 Shanghai = 06:00 UTC + expected_dt = datetime(2026, 3, 18, 14, 0, 0, tzinfo=ZoneInfo("Asia/Shanghai")) + expected_ms = int(expected_dt.timestamp() * 1000) + assert jobs[0].schedule.at_ms == expected_ms + + +@pytest.mark.asyncio +async def test_at_with_tz_aware_datetime_preserves_original(tmp_path) -> None: + """Datetime that already has tzinfo should not be overridden by tz param.""" + tool = _make_tool(tmp_path) + # Pass an aware datetime (UTC) with a different tz param + result = await tool.execute( + action="add", + message="UTC reminder", + at="2026-03-18T06:00:00+00:00", + tz="Asia/Shanghai", + ) + assert "Created job" in result + + jobs = tool._cron.list_jobs() + assert len(jobs) == 1 + # The +00:00 offset should be preserved (dt.tzinfo is not None, so tz is ignored) + expected_dt = datetime(2026, 3, 18, 6, 0, 0, tzinfo=timezone.utc) + expected_ms = int(expected_dt.timestamp() * 1000) + assert jobs[0].schedule.at_ms == expected_ms + + +@pytest.mark.asyncio +async def test_tz_without_cron_or_at_fails(tmp_path) -> None: + """Passing tz without cron_expr or at should return an error.""" + tool = _make_tool(tmp_path) + result = await tool.execute( + action="add", + message="Bad config", + tz="America/Vancouver", + ) + assert "Error" in result + assert "tz can only be used with cron_expr or at" in result + + +@pytest.mark.asyncio +async def test_at_without_tz_unchanged(tmp_path) -> None: + """Naive datetime without tz should use system-local interpretation (existing behavior).""" + tool = _make_tool(tmp_path) + result = await tool.execute( + action="add", + message="Local reminder", + at="2026-03-18T14:00:00", + ) + assert "Created job" in result + + jobs = tool._cron.list_jobs() + assert len(jobs) == 1 + # fromisoformat without tz → system local; just verify job was created + local_dt = datetime.fromisoformat("2026-03-18T14:00:00") + expected_ms = int(local_dt.timestamp() * 1000) + assert jobs[0].schedule.at_ms == expected_ms + + +@pytest.mark.asyncio +async def test_at_with_invalid_tz_fails(tmp_path) -> None: + """Invalid timezone should return an error.""" + tool = _make_tool(tmp_path) + result = await tool.execute( + action="add", + message="Bad tz", + at="2026-03-18T14:00:00", + tz="Invalid/Timezone", + ) + assert "Error" in result + assert "unknown timezone" in result