mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-04 18:32:44 +00:00
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:
parent
471c1b2bd4
commit
939af8898b
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
103
tests/test_cron_tool_tz_at.py
Normal file
103
tests/test_cron_tool_tz_at.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user