diff --git a/src/agent_loop.py b/src/agent_loop.py index 6c039b8..c9ca135 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -315,9 +315,10 @@ Bulk delete/archive/mark emails. Use this for "delete all those" after listing e {"action": "create_event", "summary": "", "dtstart": ""} ``` Calendar event management (CalDAV). Actions: `list_events`, `create_event`, `update_event`, `delete_event`, `list_calendars`. \ -For `create_event`: {summary, dtstart, dtend?, duration?, calendar?, location?, description?, reminder_minutes?}. \ +For `create_event`: {summary, dtstart, dtend?, duration?, calendar?, location?, description?, reminder_minutes?, rrule?}. \ `dtstart` accepts natural language ("tomorrow at 1pm", "in 2 hours", "next monday 9am") or ISO ("2026-05-12T13:00:00"). \ If `dtend` omitted, defaults to dtstart+1h (or +1d when `all_day: true`). \ +For a RECURRING event pass `rrule` as an iCalendar RRULE string, e.g. `"FREQ=WEEKLY;BYDAY=MO"` (every Monday), `"FREQ=DAILY;COUNT=10"`, or `"FREQ=MONTHLY;BYMONTHDAY=1"` — create ONE event with the rrule, do not loop creating many events. \ If the user asks for a reminder/alarm before the event, pass `reminder_minutes` as an integer; do not write reminder text into the event description and do NOT also call `manage_notes` for the same reminder because calendar reminders are routed through Notes automatically. \ `calendar` accepts a name ("Main") or short-id prefix.""", "create_session": "- ```create_session``` — Create a new chat. Line 1 = chat name, line 2 = model name. Use for background/parallel work.", diff --git a/tests/test_calendar_rrule.py b/tests/test_calendar_rrule.py new file mode 100644 index 0000000..40047b7 --- /dev/null +++ b/tests/test_calendar_rrule.py @@ -0,0 +1,79 @@ +"""Issue #1320 — the agent's manage_calendar tool can create a recurring event. + +The create_event handler already persists `rrule`, but it wasn't documented in the +tool schema, so the agent took "a roundabout way". This pins the end-to-end path: +calling do_manage_calendar with an rrule stores a single event carrying that RRULE. +""" + +import json +import tempfile +import uuid + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import CalendarEvent + +_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_ENGINE = create_engine( + f"sqlite:///{_TMPDB.name}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, +) +cdb.Base.metadata.create_all(_ENGINE) +_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False) + + +@pytest.fixture(autouse=True) +def _bind_temp_db(monkeypatch): + # do_manage_calendar does `from core.database import SessionLocal` at call + # time, so patch the module attribute to our temp DB — via monkeypatch so it + # is RESTORED after each test and can't leak into later tests in the process. + monkeypatch.setattr(cdb, "SessionLocal", _TS) + yield + + +async def test_create_event_with_rrule_persists_recurrence(): + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + rrule = "FREQ=WEEKLY;BYDAY=MO" + res = await do_manage_calendar(json.dumps({ + "action": "create_event", + "summary": "Standup", + "dtstart": "2026-06-08T09:00:00Z", + "rrule": rrule, + }), owner=owner) + assert res.get("exit_code", 0) == 0, res + uid = res.get("uid") + assert uid, res + + db = _TS() + try: + ev = db.query(CalendarEvent).filter(CalendarEvent.uid == uid).first() + assert ev is not None + assert ev.rrule == rrule # ONE event carrying the recurrence rule + assert ev.summary == "Standup" + finally: + db.close() + + +async def test_create_event_without_rrule_is_single(): + from src.tool_implementations import do_manage_calendar + + owner = "tester-" + uuid.uuid4().hex[:6] + res = await do_manage_calendar(json.dumps({ + "action": "create_event", + "summary": "One-off", + "dtstart": "2026-06-09T10:00:00Z", + }), owner=owner) + assert res.get("exit_code", 0) == 0, res + db = _TS() + try: + ev = db.query(CalendarEvent).filter(CalendarEvent.uid == res["uid"]).first() + assert ev is not None and (ev.rrule or "") == "" + finally: + db.close()