Add design for recurring Google Calendar events
Extends the calendar tool to create, modify, and delete recurring events via raw RFC 5545 recurrence lines, with instance vs. series scope on update/delete and a list_instances action to fetch a series' occurrences.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# Recurring Google Calendar events
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the `calendar` tool in `src/tools/calendar.ts` so the agent can create, modify, and delete recurring events in the owner's Google Calendar. Today the tool sets `singleEvents=true` on list and provides no way to write a `recurrence` field, so recurring events can only be observed (as expanded occurrences) and never authored or altered.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- "This and following" edits. Google has no native endpoint for splitting a series; emulating it (patching the master's RRULE with an `UNTIL` and creating a new series) has enough edge cases that we defer it. If it turns out to matter in practice we add it later.
|
||||
- Reminders, attendees, transparency, conferencing, colour, and other event fields the current tool doesn't already touch.
|
||||
- Full RFC 5545 parsing/validation. We accept RRULE strings verbatim and let the Google API be the authority on their correctness.
|
||||
|
||||
## Recurrence input shape
|
||||
|
||||
The agent supplies recurrence as raw RFC 5545 lines, matching Google Calendar's `event.recurrence` field 1:1.
|
||||
|
||||
New optional parameter on `calendarParams`:
|
||||
|
||||
- `recurrence: string[]` — used on `create` and `update` when the target is a series master. Each entry must start with one of `RRULE:`, `EXDATE:`, `RDATE:`, `EXRULE:`. Typical value: `["RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T235959Z"]`. Passing `[]` on `update` strips recurrence and turns a series back into a single event.
|
||||
|
||||
## Scope resolution for update / delete
|
||||
|
||||
Google Calendar distinguishes a series master (an event with `recurrence`) from an occurrence (an event whose `recurringEventId` points at a master). Edits to a master change every occurrence; edits to an occurrence change (or cancel) just that one date.
|
||||
|
||||
New optional parameter:
|
||||
|
||||
- `scope: "instance" | "series"` — used on `update` and `delete`.
|
||||
|
||||
Resolution rule:
|
||||
|
||||
1. If the caller passes `scope` explicitly, use it.
|
||||
2. Otherwise fetch the event referenced by `event_id` once. If it has `recurringEventId`, default `scope=instance`. Else if it has `recurrence`, default `scope=series`. Else (plain single event), `scope` is ignored.
|
||||
|
||||
Validation:
|
||||
|
||||
- `scope=instance` on an event with no `recurringEventId` → error `event_id is a series master; pass an instance id or use scope=series`.
|
||||
- `scope=instance` with `recurrence` in the same call → error `recurrence can only be set on the series master (scope=series)`.
|
||||
|
||||
For `delete`, `scope=instance` cancels one occurrence (Google records it as `status: "cancelled"`); `scope=series` deletes the master, removing every occurrence.
|
||||
|
||||
## Listing
|
||||
|
||||
Existing `list` action keeps `singleEvents=true` as the default and continues to expand recurrences into occurrences. Additions:
|
||||
|
||||
- New optional parameter `list_mode: "occurrences" | "series"`. Default `occurrences`.
|
||||
- `occurrences` — current behaviour. Output lines for occurrences whose `recurringEventId` is set are suffixed with ` · recurring (series=<id>)` so the agent can pivot to a series-scoped edit.
|
||||
- `series` — sets `singleEvents=false`. Only master events come back. Each output line includes the RRULE joined from `event.recurrence`.
|
||||
|
||||
New action **`list_instances`**:
|
||||
|
||||
- Required: `event_id` (a series master id).
|
||||
- Optional: `time_min`, `time_max`, `max_results` (default 25, capped 250, same as `list`).
|
||||
- Calls `GET /calendars/{calId}/events/{event_id}/instances` with those params.
|
||||
- Output: one line per occurrence formatted like a normal occurrence line, without the recurring suffix.
|
||||
|
||||
The purpose of `list_instances` is to give the agent a way to obtain the instance id for a specific date so it can then act on that occurrence with `scope=instance`.
|
||||
|
||||
## API surface summary
|
||||
|
||||
`calendarParams.action` gains `"list_instances"`, joining `list`, `create`, `update`, `delete`.
|
||||
|
||||
New optional params (all typed and described so the agent picks them correctly):
|
||||
|
||||
- `recurrence: string[]`
|
||||
- `scope: "instance" | "series"`
|
||||
- `list_mode: "occurrences" | "series"`
|
||||
|
||||
Every existing param keeps its current name and semantics.
|
||||
|
||||
## Behaviour per action
|
||||
|
||||
**create.** Unchanged path, plus: if `recurrence` is present, include it in the POST body. `start`/`end` continue to define DTSTART/DTEND of the first occurrence.
|
||||
|
||||
**update.** Resolve scope as above.
|
||||
- `scope=series` → `PATCH /events/{event_id}` with the event body built from whatever fields the caller passed. `recurrence` in the body replaces the master's rule; `[]` strips it.
|
||||
- `scope=instance` → `PATCH /events/{event_id}` where `event_id` is the instance id. `recurrence` is rejected here.
|
||||
|
||||
**delete.** Resolve scope as above.
|
||||
- `scope=series` → `DELETE /events/{event_id}` on the master.
|
||||
- `scope=instance` → `DELETE /events/{event_id}` on the instance id.
|
||||
|
||||
**list.** Two modes, one action. `occurrences` (default) is the current call with `singleEvents=true` plus the recurring marker on output. `series` sets `singleEvents=false` and shows RRULE per line.
|
||||
|
||||
**list_instances.** `GET /calendars/{calId}/events/{event_id}/instances`.
|
||||
|
||||
## Output format
|
||||
|
||||
Extending `describeEvent` and adding formatters. Formats:
|
||||
|
||||
- Occurrence in `list` (occurrences): `<id> · <summary> · <when>[ @ <loc>][ · recurring (series=<seriesId>)]`.
|
||||
- Series master in `list` (series) and in create/update responses when `recurrence` is set: `<id> · <summary> · <first-start> · RRULE=<joined recurrence lines>[ @ <loc>]`. `<first-start>` uses the master's DTSTART returned by Google.
|
||||
- `list_instances`: one occurrence line per instance, no recurring suffix.
|
||||
- Delete of an instance: `Cancelled occurrence <instance_id> of series <seriesId>.`
|
||||
- Delete of a series: `Deleted series <event_id> (all occurrences).`
|
||||
- Delete of a plain single event: unchanged (`Deleted event <event_id>.`).
|
||||
|
||||
## Errors
|
||||
|
||||
- `list_instances` without `event_id` → `list_instances requires event_id (the series master id)`.
|
||||
- `update`/`delete` with `scope=instance` on a non-instance → `event_id is a series master; pass an instance id or use scope=series`.
|
||||
- `update` with `recurrence` and `scope=instance` → `recurrence can only be set on the series master (scope=series)`.
|
||||
- `recurrence` entries not starting with `RRULE:`, `EXDATE:`, `RDATE:`, or `EXRULE:` → `recurrence entries must be RFC 5545 lines (RRULE:..., EXDATE:..., RDATE:..., EXRULE:...)`.
|
||||
- Google API failures continue to surface through `ensureOk` unchanged.
|
||||
|
||||
## Types
|
||||
|
||||
`CalendarEvent` gains two optional fields matching Google's payload:
|
||||
|
||||
- `recurringEventId?: string`
|
||||
- `recurrence?: string[]`
|
||||
|
||||
Everything else in the existing interface stays as-is.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests in a new `src/tools/calendar.test.ts` with a mocked `fetch`:
|
||||
|
||||
- `create` with `recurrence` includes it in the request body.
|
||||
- `update` with default scope on an id whose fetched event has `recurringEventId` PATCHes as an instance.
|
||||
- `update` with default scope on an id whose fetched event has `recurrence` PATCHes as a series.
|
||||
- `update scope=instance` rejects when the fetched event has no `recurringEventId`.
|
||||
- `update scope=instance` rejects a `recurrence` param.
|
||||
- `delete scope=instance` DELETEs the instance id and returns the cancelled-occurrence message.
|
||||
- `delete scope=series` DELETEs the master and returns the series message.
|
||||
- `list list_mode=series` sets `singleEvents=false` and output lines include `RRULE=…`.
|
||||
- `list` output marks occurrences whose `recurringEventId` is set with the recurring suffix.
|
||||
- `list_instances` GETs the instances endpoint with `timeMin`/`timeMax`/`maxResults`.
|
||||
- Invalid `recurrence` entry (missing `RRULE:` prefix on any element) is rejected.
|
||||
|
||||
No new live/integration tests. README's manual E2E checklist gains one entry: create a weekly recurring event, list its next few instances, move one instance, delete the whole series.
|
||||
|
||||
## Out of scope reminders
|
||||
|
||||
- No "this and following" edits.
|
||||
- No support for editing attendees, reminders, timezone overrides, colour, or other fields the tool doesn't already touch.
|
||||
- No client-side RRULE parsing.
|
||||
Reference in New Issue
Block a user