Skip to content

Commit a42782d

Browse files
committed
fix(outlook): don't promote a lone date-only bound to all-day on update
Regression from the previous round's date-only promotion. A single date-only startDateTime or endDateTime satisfied "all provided bounds are date-only", so the tool promoted to all-day and derived the missing side from the supplied one — turning a partial reschedule of a timed or multi-day event into a one-day all-day event and dropping the original other bound. Implicit promotion now requires BOTH bounds to be date-only, matching calendar_create_event. A lone date-only bound is ambiguous (convert to all-day, or just move that edge?) and a PATCH cannot read the event's existing bounds to disambiguate, so it stays on the timed path and leaves the other side untouched. Deriving a missing bound remains allowed when isAllDay is set explicitly, since that is stated intent rather than a guess. Also pins the explicit-isAllDay-false + date-only override with a regression test: the block always sends isAllDay for create, so an untouched switch arrives as false, and the data shape has to win or the fix would be unreachable from the UI.
1 parent 170ddb8 commit a42782d

2 files changed

Lines changed: 77 additions & 8 deletions

File tree

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,71 @@ describe('outlook calendar tools', () => {
116116
endDateTime: '2025-06-03T11:00:00Z',
117117
})
118118
expect(timed.isAllDay).toBeUndefined()
119+
120+
// Mixed date-only + timed is a timed event starting at midnight, not all-day.
121+
const mixed = createBody({
122+
accessToken: 't',
123+
subject: 'Sync',
124+
startDateTime: '2025-06-03',
125+
endDateTime: '2025-06-03T11:00:00Z',
126+
})
127+
expect(mixed.isAllDay).toBeUndefined()
128+
})
129+
130+
it('promotes date-only bounds even when isAllDay is explicitly false', () => {
131+
// The block always sends isAllDay for create, so an untouched All Day switch arrives
132+
// as `false`. Date-only bounds have no time, so honoring that false would emit a
133+
// zero-length midnight window — the shape wins over the flag, by design.
134+
const createBody = outlookCalendarCreateEventTool.request.body as (
135+
p: unknown
136+
) => Record<string, unknown>
137+
const created = createBody({
138+
accessToken: 't',
139+
subject: 'Company holiday',
140+
startDateTime: '2025-06-03',
141+
endDateTime: '2025-06-03',
142+
isAllDay: false,
143+
})
144+
expect(created.isAllDay).toBe(true)
145+
expect(created.end).toEqual({ dateTime: '2025-06-04T00:00:00', timeZone: 'UTC' })
146+
147+
const updateBody = outlookCalendarUpdateEventTool.request.body as (
148+
p: unknown
149+
) => Record<string, unknown>
150+
const updated = updateBody({
151+
accessToken: 't',
152+
eventId: 'e1',
153+
startDateTime: '2025-06-03',
154+
endDateTime: '2025-06-04',
155+
isAllDay: false,
156+
})
157+
expect(updated.isAllDay).toBe(true)
158+
})
159+
160+
it('does not promote a lone date-only bound on update', () => {
161+
const body = outlookCalendarUpdateEventTool.request.body as (
162+
p: unknown
163+
) => Record<string, unknown>
164+
// Moving only the start of an existing timed/multi-day event must not collapse it into
165+
// a one-day all-day event: the other bound is left untouched for Graph to preserve.
166+
const movedStart = body({ accessToken: 't', eventId: 'e1', startDateTime: '2025-06-10' })
167+
expect(movedStart.isAllDay).toBeUndefined()
168+
expect(movedStart.start).toEqual({ dateTime: '2025-06-10T00:00:00', timeZone: 'UTC' })
169+
expect('end' in movedStart).toBe(false)
170+
171+
const movedEnd = body({ accessToken: 't', eventId: 'e1', endDateTime: '2025-06-12' })
172+
expect(movedEnd.isAllDay).toBeUndefined()
173+
expect('start' in movedEnd).toBe(false)
174+
175+
// An explicit isAllDay:true is stated intent, so deriving the missing bound is allowed.
176+
const explicit = body({
177+
accessToken: 't',
178+
eventId: 'e1',
179+
isAllDay: true,
180+
startDateTime: '2025-06-10',
181+
})
182+
expect(explicit.isAllDay).toBe(true)
183+
expect(explicit.end).toEqual({ dateTime: '2025-06-11T00:00:00', timeZone: 'UTC' })
119184
})
120185

121186
it('rejects an all-day conversion that supplies no bounds', () => {

apps/sim/tools/outlook/calendar_update_event.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,16 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
132132
// PATCH is a partial update: only include fields the caller actually provided.
133133
const event: Record<string, unknown> = {}
134134

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).
137135
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
136+
const explicitAllDay = params.isAllDay !== undefined && toBool(params.isAllDay)
137+
// Promote implicitly only when BOTH bounds are date-only (matching
138+
// calendar_create_event). A *single* date-only bound is ambiguous — it could mean
139+
// "convert to all-day" or "just move the start" — and a PATCH cannot read the
140+
// event's existing bounds to tell. Deriving the missing side would silently collapse
141+
// a timed or multi-day event into a one-day all-day event, so a lone date-only bound
142+
// stays on the timed path and leaves the other side untouched.
143+
const bothBoundsDateOnly = isDateOnly(params.startDateTime) && isDateOnly(params.endDateTime)
144+
const settingAllDay = explicitAllDay || bothBoundsDateOnly
142145

143146
if (params.subject !== undefined) {
144147
event.subject = params.subject
@@ -153,8 +156,9 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
153156
'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.'
154157
)
155158
}
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.
159+
// Normalize both bounds together. The single-bound fallback only applies when the
160+
// caller set isAllDay explicitly — implicit promotion requires both bounds — so
161+
// deriving the missing side follows stated intent rather than guessing at it.
158162
const start = params.startDateTime || params.endDateTime!
159163
const end = params.endDateTime || params.startDateTime!
160164
const range = buildAllDayRange(start, end, params.timeZone)

0 commit comments

Comments
 (0)