fix(cron): support tz parameter with at for one-time scheduled tasks

The tz parameter was previously only allowed with cron_expr. When users
specified tz with at for one-time tasks, it returned an error. Now tz
works with both cron_expr and at — naive ISO datetimes are interpreted
in the given timezone via ZoneInfo.

- Relax validation: allow tz with cron_expr or at
- Apply ZoneInfo to naive datetimes in the at branch
- Update SKILL.md with at+tz examples
- Add automated tests for tz+at combinations

Co-authored-by: weitongtong <tongtong.wei@nodeskai.com>
Made-with: Cursor
This commit is contained in:
xzq.xu 2026-03-19 22:37:12 +08:00 committed by chengyongru
parent 471c1b2bd4
commit 939af8898b
3 changed files with 115 additions and 4 deletions

View File

@ -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

View File

@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time):
cron(action="add", message="Remind me about the meeting", at="<ISO datetime>")
```
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.

View File

@ -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