Skip to content

Commit 6ade310

Browse files
committed
fix(outlook): treat date-only bounds as all-day and guard partial all-day updates
Cursor round 2: - Date-only bounds no longer produce a zero-length window. The param docs invited a date like 2025-06-03 for an all-day event, but buildAllDayRange only ran when isAllDay was explicitly true, so date-only input built a 00:00->00:00 timed window that Graph rejects. A date-only bound carries no time, so the only coherent reading is all-day; create/update now promote on that shape and the descriptions state it. - Converting an event to all-day with no bounds now fails with an actionable message instead of a Graph 400. Graph requires all-day events to have midnight start and end in the same zone, and those cannot be derived from a partial PATCH against an event whose existing bounds are timed.
1 parent a0730fc commit 6ade310

5 files changed

Lines changed: 106 additions & 20 deletions

File tree

apps/docs/content/docs/en/integrations/outlook.mdx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,8 @@ List Outlook calendar events within a start/end time window
482482
| Parameter | Type | Required | Description |
483483
| --------- | ---- | -------- | ----------- |
484484
| `calendarId` | string | No | ID of the calendar to read. Defaults to the mailbox default calendar. |
485-
| `startDateTime` | string | Yes | Start of the time window \(ISO 8601, e.g. 2025-06-03T00:00:00-08:00\). Interpreted as UTC if no offset is given. |
486-
| `endDateTime` | string | Yes | End of the time window \(ISO 8601, e.g. 2025-06-10T00:00:00-08:00\). Interpreted as UTC if no offset is given. |
485+
| `startDateTime` | string | No | Start of the time window \(ISO 8601, e.g. 2025-06-03T00:00:00-08:00\). Interpreted as UTC if no offset is given. Required unless paging with pageToken. |
486+
| `endDateTime` | string | No | End of the time window \(ISO 8601, e.g. 2025-06-10T00:00:00-08:00\). Interpreted as UTC if no offset is given. Required unless paging with pageToken. |
487487
| `maxResults` | number | No | Maximum number of events to return per page \(default: 10, max: 100\) |
488488
| `orderBy` | string | No | Order of events \(default: start/dateTime\) |
489489
| `pageToken` | string | No | Full @odata.nextLink URL from a previous page to continue paging |
@@ -567,8 +567,8 @@ Create a new Outlook calendar event
567567
| --------- | ---- | -------- | ----------- |
568568
| `calendarId` | string | No | ID of the calendar to create the event in. Defaults to the default calendar. |
569569
| `subject` | string | Yes | Event subject/title |
570-
| `startDateTime` | string | Yes | Start time \(ISO 8601, e.g. 2025-06-03T10:00:00-08:00\) or a date \(2025-06-03\) for an all-day event |
571-
| `endDateTime` | string | Yes | End time \(ISO 8601, e.g. 2025-06-03T11:00:00-08:00\) or a date \(2025-06-04\) for an all-day event |
570+
| `startDateTime` | string | Yes | Start time \(ISO 8601, e.g. 2025-06-03T10:00:00-08:00\). A date-only value \(2025-06-03\) for both start and end creates an all-day event. |
571+
| `endDateTime` | string | Yes | End time \(ISO 8601, e.g. 2025-06-03T11:00:00-08:00\). A date-only value \(2025-06-04\) for both start and end creates an all-day event. |
572572
| `timeZone` | string | No | IANA or Windows time zone name \(e.g. America/Los_Angeles\). Used for datetimes without a UTC offset. Defaults to UTC. |
573573
| `body` | string | No | Event body content |
574574
| `contentType` | string | No | Content type for the event body \(text or html\) |
@@ -616,14 +616,14 @@ Update an existing Outlook calendar event
616616
| --------- | ---- | -------- | ----------- |
617617
| `eventId` | string | Yes | The ID of the calendar event to update |
618618
| `subject` | string | No | New event subject/title |
619-
| `startDateTime` | string | No | New start time \(ISO 8601\) or a date \(2025-06-03\) for an all-day event |
620-
| `endDateTime` | string | No | New end time \(ISO 8601\) or a date \(2025-06-04\) for an all-day event |
619+
| `startDateTime` | string | No | New start time \(ISO 8601\). A date-only value \(2025-06-03\) converts the event to all-day. |
620+
| `endDateTime` | string | No | New end time \(ISO 8601\). A date-only value \(2025-06-04\) converts the event to all-day. |
621621
| `timeZone` | string | No | IANA or Windows time zone name applied to updated datetimes without a UTC offset. Defaults to UTC. |
622622
| `body` | string | No | New event body content |
623623
| `contentType` | string | No | Content type for the event body \(text or html\) |
624624
| `location` | string | No | New event location display name |
625625
| `attendees` | string | No | Replacement attendee email addresses \(comma-separated\) |
626-
| `isAllDay` | boolean | No | Whether the event lasts the entire day |
626+
| `isAllDay` | boolean | No | Whether the event lasts the entire day. Setting this true requires also sending startDateTime, since Graph needs midnight bounds. |
627627
| `isOnlineMeeting` | boolean | No | Attach an online meeting to the event. Uses the mailbox default provider \(Teams on work/school accounts\); personal accounts have no supported online-meeting provider. |
628628

629629
#### Output

apps/sim/tools/outlook/calendar-tools.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,56 @@ describe('outlook calendar tools', () => {
8080
)
8181
})
8282

83+
it('treats date-only bounds as an all-day event even without the flag', () => {
84+
const createBody = outlookCalendarCreateEventTool.request.body as (
85+
p: unknown
86+
) => Record<string, unknown>
87+
// Date-only carries no time, so this must not become a zero-length 00:00->00:00 window.
88+
const created = createBody({
89+
accessToken: 't',
90+
subject: 'Company holiday',
91+
startDateTime: '2025-06-03',
92+
endDateTime: '2025-06-03',
93+
})
94+
expect(created.isAllDay).toBe(true)
95+
expect(created.start).toEqual({ dateTime: '2025-06-03T00:00:00', timeZone: 'UTC' })
96+
expect(created.end).toEqual({ dateTime: '2025-06-04T00:00:00', timeZone: 'UTC' })
97+
98+
const updateBody = outlookCalendarUpdateEventTool.request.body as (
99+
p: unknown
100+
) => Record<string, unknown>
101+
const updated = updateBody({
102+
accessToken: 't',
103+
eventId: 'e1',
104+
startDateTime: '2025-06-03',
105+
endDateTime: '2025-06-05',
106+
})
107+
expect(updated.isAllDay).toBe(true)
108+
expect(updated.start).toEqual({ dateTime: '2025-06-03T00:00:00', timeZone: 'UTC' })
109+
expect(updated.end).toEqual({ dateTime: '2025-06-05T00:00:00', timeZone: 'UTC' })
110+
111+
// A timed bound stays timed.
112+
const timed = createBody({
113+
accessToken: 't',
114+
subject: 'Sync',
115+
startDateTime: '2025-06-03T10:00:00Z',
116+
endDateTime: '2025-06-03T11:00:00Z',
117+
})
118+
expect(timed.isAllDay).toBeUndefined()
119+
})
120+
121+
it('rejects an all-day conversion that supplies no bounds', () => {
122+
const body = outlookCalendarUpdateEventTool.request.body as (
123+
p: unknown
124+
) => Record<string, unknown>
125+
// Graph needs midnight bounds; they cannot be derived from a partial update.
126+
expect(() => body({ accessToken: 't', eventId: 'e1', isAllDay: true })).toThrow(
127+
/requires startDateTime/
128+
)
129+
// Turning all-day off without bounds is still a valid partial update.
130+
expect(body({ accessToken: 't', eventId: 'e1', isAllDay: false })).toEqual({ isAllDay: false })
131+
})
132+
83133
it('normalizes an all-day update when only one bound is supplied', () => {
84134
const body = outlookCalendarUpdateEventTool.request.body as (
85135
p: unknown

apps/sim/tools/outlook/calendar-utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ export function buildGraphEventDateTime(value: string, timeZone?: string): Graph
105105
return { dateTime: trimmed, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE }
106106
}
107107

108+
/**
109+
* True when a bound carries a date but no time (`2025-06-03`).
110+
*
111+
* A date-only bound has no time component, so the only coherent reading is an all-day
112+
* event — the create/update tools promote such a pair rather than emitting a zero-length
113+
* midnight-to-midnight timed window, which Graph rejects.
114+
*/
115+
export function isDateOnly(value: string | undefined): boolean {
116+
return Boolean(value) && !value!.includes('T')
117+
}
118+
108119
/** Extract the `YYYY-MM-DD` date portion from a date or datetime string. */
109120
function toDateOnly(value: string): string {
110121
const trimmed = value.trim()

apps/sim/tools/outlook/calendar_create_event.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildGraphEventDateTime,
66
CALENDAR_RETRY,
77
flattenGraphEvent,
8+
isDateOnly,
89
normalizeAttendees,
910
} from '@/tools/outlook/calendar-utils'
1011
import type {
@@ -58,14 +59,14 @@ export const outlookCalendarCreateEventTool: ToolConfig<
5859
required: true,
5960
visibility: 'user-or-llm',
6061
description:
61-
'Start time (ISO 8601, e.g. 2025-06-03T10:00:00-08:00) or a date (2025-06-03) for an all-day event',
62+
'Start time (ISO 8601, e.g. 2025-06-03T10:00:00-08:00). A date-only value (2025-06-03) for both start and end creates an all-day event.',
6263
},
6364
endDateTime: {
6465
type: 'string',
6566
required: true,
6667
visibility: 'user-or-llm',
6768
description:
68-
'End time (ISO 8601, e.g. 2025-06-03T11:00:00-08:00) or a date (2025-06-04) for an all-day event',
69+
'End time (ISO 8601, e.g. 2025-06-03T11:00:00-08:00). A date-only value (2025-06-04) for both start and end creates an all-day event.',
6970
},
7071
timeZone: {
7172
type: 'string',
@@ -127,7 +128,12 @@ export const outlookCalendarCreateEventTool: ToolConfig<
127128
}
128129
},
129130
body: (params) => {
130-
const isAllDay = toBool(params.isAllDay)
131+
// Date-only bounds carry no time, so they can only mean an all-day event. Without
132+
// this promotion, `2025-06-03` for both bounds would build a zero-length
133+
// 00:00->00:00 timed window that Graph rejects, even though the param docs invite
134+
// date-only input for all-day events.
135+
const bothBoundsDateOnly = isDateOnly(params.startDateTime) && isDateOnly(params.endDateTime)
136+
const isAllDay = toBool(params.isAllDay) || bothBoundsDateOnly
131137
const event: Record<string, unknown> = { subject: params.subject }
132138

133139
if (isAllDay) {

apps/sim/tools/outlook/calendar_update_event.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildGraphEventDateTime,
66
CALENDAR_RETRY,
77
flattenGraphEvent,
8+
isDateOnly,
89
normalizeAttendees,
910
} from '@/tools/outlook/calendar-utils'
1011
import type {
@@ -57,13 +58,15 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
5758
type: 'string',
5859
required: false,
5960
visibility: 'user-or-llm',
60-
description: 'New start time (ISO 8601) or a date (2025-06-03) for an all-day event',
61+
description:
62+
'New start time (ISO 8601). A date-only value (2025-06-03) converts the event to all-day.',
6163
},
6264
endDateTime: {
6365
type: 'string',
6466
required: false,
6567
visibility: 'user-or-llm',
66-
description: 'New end time (ISO 8601) or a date (2025-06-04) for an all-day event',
68+
description:
69+
'New end time (ISO 8601). A date-only value (2025-06-04) converts the event to all-day.',
6770
},
6871
timeZone: {
6972
type: 'string',
@@ -100,7 +103,8 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
100103
type: 'boolean',
101104
required: false,
102105
visibility: 'user-or-llm',
103-
description: 'Whether the event lasts the entire day',
106+
description:
107+
'Whether the event lasts the entire day. Setting this true requires also sending startDateTime, since Graph needs midnight bounds.',
104108
},
105109
isOnlineMeeting: {
106110
type: 'boolean',
@@ -127,17 +131,30 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
127131
body: (params) => {
128132
// PATCH is a partial update: only include fields the caller actually provided.
129133
const event: Record<string, unknown> = {}
130-
const settingAllDay = params.isAllDay !== undefined && toBool(params.isAllDay)
134+
135+
// Date-only bounds carry no time, so they can only mean an all-day event — promote
136+
// them even when the caller didn't set the flag (see calendar_create_event).
137+
const providedBounds = [params.startDateTime, params.endDateTime].filter(Boolean)
138+
const allProvidedBoundsDateOnly =
139+
providedBounds.length > 0 && providedBounds.every((bound) => isDateOnly(bound))
140+
const settingAllDay =
141+
(params.isAllDay !== undefined && toBool(params.isAllDay)) || allProvidedBoundsDateOnly
131142

132143
if (params.subject !== undefined) {
133144
event.subject = params.subject
134145
}
135146

136-
if (settingAllDay && (params.startDateTime || params.endDateTime)) {
137-
// Converting to all-day needs midnight bounds with an exclusive end day. Graph
138-
// rejects the whole PATCH if either bound is timed or the window is zero-length,
139-
// so normalize both together — falling back to the supplied bound when the caller
140-
// only gave one, which `buildAllDayRange` then advances to the next day.
147+
if (settingAllDay) {
148+
// Graph requires an all-day event's start and end to both be midnight in the same
149+
// zone, so flipping isAllDay on without bounds would 400 against whatever timed
150+
// start/end the event already has. Fail with an actionable message instead.
151+
if (providedBounds.length === 0) {
152+
throw new Error(
153+
'Converting an event to all-day requires startDateTime (and preferably endDateTime): Microsoft Graph requires all-day events to have midnight start and end bounds, which cannot be derived from a partial update.'
154+
)
155+
}
156+
// Normalize both bounds together — falling back to the supplied one when only a
157+
// single bound was given, which `buildAllDayRange` then advances to the next day.
141158
const start = params.startDateTime || params.endDateTime!
142159
const end = params.endDateTime || params.startDateTime!
143160
const range = buildAllDayRange(start, end, params.timeZone)
@@ -164,7 +181,9 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
164181
event.attendees = normalizeAttendees(params.attendees)
165182
}
166183

167-
if (params.isAllDay !== undefined) {
184+
if (settingAllDay) {
185+
event.isAllDay = true
186+
} else if (params.isAllDay !== undefined) {
168187
event.isAllDay = toBool(params.isAllDay)
169188
}
170189

0 commit comments

Comments
 (0)