Skip to content

Commit bc55db2

Browse files
committed
fix(outlook): tighten date-only detection and align online-meeting copy
Final validation pass over the calendar integration. - isDateOnly matched "contains no T", so a space-separated datetime (2025-06-03 10:00) counted as date-only: the time was discarded and the value built as '2025-06-03 10:00T00:00:00', which Graph rejects. It now matches YYYY-MM-DD strictly, and buildGraphEventDateTime normalizes the space form to ISO rather than mangling it, so a natural input works instead of 400ing. - The isOnlineMeeting param descriptions still claimed Graph 'uses the mailbox default provider' — the same unverified mechanism already removed from the code comments. They now state only what the docs and the author's live testing support: the join URL depends on the providers the mailbox allows, and stays null on personal accounts. - Adds blocks/blocks/outlook.test.ts following the repo's per-block test convention: every calendar operation resolves to a registered tool in tools.access, supplies all required tool params, emits no params the tool cannot accept, maps one-to-one onto the calendar tools, and the calendarId canonical group and sendResponse default are pinned.
1 parent 2564def commit bc55db2

6 files changed

Lines changed: 163 additions & 6 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ Create a new Outlook calendar event
575575
| `location` | string | No | Event location display name |
576576
| `attendees` | string | No | Attendee email addresses \(comma-separated\) |
577577
| `isAllDay` | boolean | No | Whether the event lasts the entire day |
578-
| `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. |
578+
| `isOnlineMeeting` | boolean | No | Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows \(Teams on work/school accounts\); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there. |
579579

580580
#### Output
581581

@@ -624,7 +624,7 @@ Update an existing Outlook calendar event
624624
| `location` | string | No | New event location display name |
625625
| `attendees` | string | No | Replacement attendee email addresses \(comma-separated\) |
626626
| `isAllDay` | boolean | No | Whether the event lasts the entire day. Setting this true requires also sending startDateTime, since Graph needs midnight bounds. |
627-
| `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. |
627+
| `isOnlineMeeting` | boolean | No | Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows \(Teams on work/school accounts\); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there. |
628628

629629
#### Output
630630

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { tools as toolRegistry } from '@/tools/registry'
6+
import { OutlookBlock } from './outlook'
7+
8+
const block = OutlookBlock
9+
10+
/** Every calendar operation exposed by the block's operation dropdown. */
11+
const CALENDAR_OPERATIONS = [
12+
'list_events_calendar',
13+
'get_event_calendar',
14+
'create_event_calendar',
15+
'update_event_calendar',
16+
'delete_event_calendar',
17+
'respond_calendar',
18+
] as const
19+
20+
/** Representative values for every calendar subBlock, as the editor would supply them. */
21+
const CALENDAR_SUBBLOCK_VALUES: Record<string, unknown> = {
22+
oauthCredential: 'cred-1',
23+
calendarId: 'cal-1',
24+
calEventId: 'evt-1',
25+
calWindowStart: '2025-06-03T00:00:00Z',
26+
calWindowEnd: '2025-06-10T00:00:00Z',
27+
calMaxResults: '25',
28+
calOrderBy: 'start/dateTime',
29+
calPageToken: '',
30+
calSubject: 'Sync',
31+
calStartDateTime: '2025-06-03T10:00:00Z',
32+
calEndDateTime: '2025-06-03T11:00:00Z',
33+
calBody: 'agenda',
34+
calContentType: 'text',
35+
calLocation: 'Room 1',
36+
calAttendees: 'a@x.com',
37+
calTimeZone: 'America/Los_Angeles',
38+
calIsAllDay: false,
39+
calIsOnlineMeeting: true,
40+
calResponseType: 'accept',
41+
calComment: 'see you',
42+
calSendResponse: 'true',
43+
}
44+
45+
describe('OutlookBlock calendar operations', () => {
46+
it.each(CALENDAR_OPERATIONS)('%s resolves to a registered tool in tools.access', (operation) => {
47+
const toolId = block.tools.config.tool?.({ operation }) as string
48+
expect(toolRegistry[toolId]).toBeDefined()
49+
expect(block.tools.access).toContain(toolId)
50+
})
51+
52+
it.each(CALENDAR_OPERATIONS)('%s supplies every required tool param', (operation) => {
53+
const toolId = block.tools.config.tool?.({ operation }) as string
54+
const mapped = block.tools.config.params?.({ operation, ...CALENDAR_SUBBLOCK_VALUES }) ?? {}
55+
const required = Object.entries(toolRegistry[toolId].params ?? {})
56+
.filter(([id, config]) => config.required && id !== 'accessToken')
57+
.map(([id]) => id)
58+
59+
const missing = required.filter((id) => mapped[id] === undefined || mapped[id] === '')
60+
expect(missing).toEqual([])
61+
})
62+
63+
it.each(CALENDAR_OPERATIONS)('%s emits no params the tool cannot accept', (operation) => {
64+
const toolId = block.tools.config.tool?.({ operation }) as string
65+
const mapped = block.tools.config.params?.({ operation, ...CALENDAR_SUBBLOCK_VALUES }) ?? {}
66+
// `operation` and the credential ride along for the executor, not the tool contract.
67+
const accepted = new Set([
68+
...Object.keys(toolRegistry[toolId].params ?? {}),
69+
'operation',
70+
'oauthCredential',
71+
])
72+
73+
const orphans = Object.keys(mapped).filter(
74+
(key) => !accepted.has(key) && mapped[key] !== undefined
75+
)
76+
expect(orphans).toEqual([])
77+
})
78+
79+
it('maps calendar operations onto calendar tools one-to-one', () => {
80+
const reachable = CALENDAR_OPERATIONS.map(
81+
(operation) => block.tools.config.tool?.({ operation }) as string
82+
)
83+
const declared = block.tools.access.filter((id) => id.startsWith('outlook_calendar_'))
84+
// No calendar tool is declared but unreachable, and none is reached twice.
85+
expect([...reachable].sort()).toEqual([...declared].sort())
86+
expect(new Set(reachable).size).toBe(reachable.length)
87+
})
88+
89+
it('routes the calendar picker through the calendarId canonical param', () => {
90+
const members = block.subBlocks.filter((s) => s.canonicalParamId === 'calendarId')
91+
expect(members.map((s) => s.id).sort()).toEqual(['calendarSelector', 'manualCalendarId'])
92+
// Exactly one basic-mode member, per the canonical-group contract.
93+
expect(members.filter((s) => s.mode === 'basic')).toHaveLength(1)
94+
// canonicalParamId must never collide with a subBlock id.
95+
expect(block.subBlocks.some((s) => s.id === 'calendarId')).toBe(false)
96+
// A blank pick means "default calendar", so it must not be forwarded.
97+
const mapped = block.tools.config.params?.({
98+
operation: 'create_event_calendar',
99+
...CALENDAR_SUBBLOCK_VALUES,
100+
calendarId: ' ',
101+
})
102+
expect(mapped?.calendarId).toBeUndefined()
103+
})
104+
105+
it('always sends sendResponse so an unset dropdown cannot read as "do not notify"', () => {
106+
const withoutChoice = block.tools.config.params?.({
107+
operation: 'respond_calendar',
108+
...CALENDAR_SUBBLOCK_VALUES,
109+
calSendResponse: undefined,
110+
})
111+
expect(withoutChoice?.sendResponse).toBe(true)
112+
113+
const declined = block.tools.config.params?.({
114+
operation: 'respond_calendar',
115+
...CALENDAR_SUBBLOCK_VALUES,
116+
calSendResponse: 'false',
117+
})
118+
expect(declined?.sendResponse).toBe(false)
119+
})
120+
})

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,34 @@ import {
77
buildGraphEventDateTime,
88
DEFAULT_OUTLOOK_TIME_ZONE,
99
flattenGraphEvent,
10+
isDateOnly,
1011
normalizeAttendees,
1112
} from '@/tools/outlook/calendar-utils'
1213
import type { GraphEvent } from '@/tools/outlook/types'
1314

15+
describe('date-only detection', () => {
16+
it('matches only a bare YYYY-MM-DD', () => {
17+
expect(isDateOnly('2025-06-03')).toBe(true)
18+
expect(isDateOnly(' 2025-06-03 ')).toBe(true)
19+
expect(isDateOnly(undefined)).toBe(false)
20+
expect(isDateOnly('2025-06-03T10:00:00Z')).toBe(false)
21+
// Lacks a `T` but carries a time — must not be mistaken for an all-day bound, or the
22+
// time would be discarded and the value would build as `... 10:00T00:00:00`.
23+
expect(isDateOnly('2025-06-03 10:00')).toBe(false)
24+
})
25+
26+
it('normalizes a space-separated datetime instead of mangling it', () => {
27+
expect(buildGraphEventDateTime('2025-06-03 10:00')).toEqual({
28+
dateTime: '2025-06-03T10:00',
29+
timeZone: 'UTC',
30+
})
31+
expect(buildGraphEventDateTime('2025-06-03 10:00:30', 'America/Los_Angeles')).toEqual({
32+
dateTime: '2025-06-03T10:00:30',
33+
timeZone: 'America/Los_Angeles',
34+
})
35+
})
36+
})
37+
1438
describe('buildGraphEventDateTime', () => {
1539
it('treats a date-only value as an all-day midnight start in the given zone', () => {
1640
expect(buildGraphEventDateTime('2025-06-03', 'America/Los_Angeles')).toEqual({

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ export interface GraphAttendeeInput {
9393
* - A naive datetime is treated as wall-clock time in the provided (or default) zone.
9494
*/
9595
export function buildGraphEventDateTime(value: string, timeZone?: string): GraphDateTimeTimeZone {
96-
const trimmed = value.trim()
96+
// Accept `YYYY-MM-DD HH:MM` as well as the ISO `T` form. Without this the space variant
97+
// falls through to the date-only branch and yields `2025-06-03 10:00T00:00:00`, which
98+
// Graph rejects — and it is a natural thing for a caller to type.
99+
const trimmed = value.trim().replace(SPACE_SEPARATED_DATETIME_PATTERN, '$1T$2')
97100

98101
if (!trimmed.includes('T')) {
99102
return { dateTime: `${trimmed}T00:00:00`, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE }
@@ -115,15 +118,25 @@ export function buildGraphEventDateTime(value: string, timeZone?: string): Graph
115118
return { dateTime: trimmed, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE }
116119
}
117120

121+
/** Exactly `YYYY-MM-DD`, with no time component. */
122+
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
123+
124+
/** `YYYY-MM-DD HH:MM[:SS]` — a space where ISO 8601 wants `T`. */
125+
const SPACE_SEPARATED_DATETIME_PATTERN = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)/
126+
118127
/**
119128
* True when a bound carries a date but no time (`2025-06-03`).
120129
*
121130
* A date-only bound has no time component, so the only coherent reading is an all-day
122131
* event — the create/update tools promote such a pair rather than emitting a zero-length
123132
* midnight-to-midnight timed window, which Graph rejects.
133+
*
134+
* Matched strictly rather than as "contains no `T`": a space-separated datetime
135+
* (`2025-06-03 10:00`) also lacks a `T`, and treating it as date-only would both discard
136+
* the caller's time and build a malformed `2025-06-03 10:00T00:00:00` value.
124137
*/
125138
export function isDateOnly(value: string | undefined): boolean {
126-
return Boolean(value) && !value!.includes('T')
139+
return Boolean(value) && DATE_ONLY_PATTERN.test(value!.trim())
127140
}
128141

129142
/** Extract the `YYYY-MM-DD` date portion from a date or datetime string. */

apps/sim/tools/outlook/calendar_create_event.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export const outlookCalendarCreateEventTool: ToolConfig<
112112
required: false,
113113
visibility: 'user-or-llm',
114114
description:
115-
'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.',
115+
'Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows (Teams on work/school accounts); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there.',
116116
},
117117
},
118118

apps/sim/tools/outlook/calendar_update_event.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export const outlookCalendarUpdateEventTool: ToolConfig<
111111
required: false,
112112
visibility: 'user-or-llm',
113113
description:
114-
'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.',
114+
'Attach an online meeting to the event. The join URL depends on the meeting providers the mailbox allows (Teams on work/school accounts); personal accounts have no supported provider, so onlineMeeting.joinUrl stays null there.',
115115
},
116116
},
117117

0 commit comments

Comments
 (0)