Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions packages/junior-google/src/tools/find-meeting-times.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ const inputSchema = z
attendees: emailListSchema(20)
.min(1)
.describe(
"Work emails of the people who must attend. Resolve names with userLookup. Junior adds the requester automatically when it knows their email.",
"Work emails of the people who must attend. Resolve names with userLookup. The runtime adds the requester as required when it knows their email. To make the requester optional, put their email in optionalAttendees.",
),
optionalAttendees: emailListSchema(20)
.default([])
.describe(
'Work emails of people the requester calls optional or "if free". Their conflicts do not block a slot; slots where they are free come first.',
'Work emails of people the requester calls optional or "if free". Their conflicts do not block a slot; slots where they are free come first. Put the requester here when their own calendar must not block a slot, such as when they say some events are only holds.',
),
durationMinutes: z.number().int().min(15).max(480).default(30),
timeMin: z.iso
Expand Down Expand Up @@ -378,9 +378,14 @@ export function createFindMeetingTimesTool(ctx: GoogleToolContext) {
"workdayEnd must be after workdayStart.",
);
}
const attendees = await withRequester(ctx, input.attendees);
// An explicit optional entry for the requester wins over the automatic add.
const optionalEmails = new Set(input.optionalAttendees);
const attendees = (await withRequester(ctx, input.attendees)).filter(
(email) =>
input.attendees.includes(email) || !optionalEmails.has(email),
);
const required = new Set(attendees);
const optionalAttendees = [...new Set(input.optionalAttendees)].filter(
const optionalAttendees = [...optionalEmails].filter(
(email) => !required.has(email),
);
const everyone = [...attendees, ...optionalAttendees];
Expand Down
22 changes: 15 additions & 7 deletions packages/junior-google/tests/calendar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,12 @@ describe("Google Calendar tools", () => {
const { fetch, tools } = calendarTools(
Response.json({
calendars: {
[REQUESTER]: { busy: [] },
// The requester says this all-day block is only a hold.
[REQUESTER]: {
busy: [
{ start: "2026-10-12T16:00:00Z", end: "2026-10-13T00:00:00Z" },
],
},
"bob@example.com": {
busy: [
{ start: "2026-10-12T16:00:00Z", end: "2026-10-12T17:00:00Z" },
Expand All @@ -138,15 +143,16 @@ describe("Google Calendar tools", () => {
"carol@example.com": { busy: [], errors: [{ reason: "notFound" }] },
},
}),
Response.json({ timeZone: LA }),
Response.json({ timeZone: "America/New_York" }),
Response.json({}, { status: 404 }),
Response.json({ timeZone: LA }),
);

const result = await tools.findMeetingTimes!.execute!(
tools.findMeetingTimes!.prepareArguments!({
attendees: ["Bob@example.com", "carol@example.com"],
maxResults: 1,
optionalAttendees: [REQUESTER],
timeMax: "2026-10-13T00:00:00Z",
timeMin: "2026-10-12T16:00:00Z",
timeZone: LA,
Expand All @@ -158,25 +164,27 @@ describe("Google Calendar tools", () => {
expect(freeBusy.operation).toBe("google.calendar.freebusy.query");
expect(freeBusy.provider).toBe("google");
expect((await freeBusy.request.json()).items).toEqual([
{ id: REQUESTER },
{ id: "bob@example.com" },
{ id: "carol@example.com" },
{ id: REQUESTER },
]);
const bobZone = new URL(timeZones[1].request.url);
expect(timeZones[1].operation).toBe("google.calendar.events.list");
const bobZone = new URL(timeZones[0].request.url);
expect(timeZones[0].operation).toBe("google.calendar.events.list");
expect(bobZone.pathname).toBe(
"/calendar/v3/calendars/bob%40example.com/events",
);
// Without a timeZone parameter, Google returns the calendar's own zone.
expect(bobZone.searchParams.has("timeZone")).toBe(false);
expect(result).toMatchObject({
checked: [
{ email: REQUESTER, timeZone: LA },
{ email: "bob@example.com", timeZone: "America/New_York" },
{ email: REQUESTER, timeZone: LA },
],
// 10:00 in Los Angeles, after Bob's 12:00-13:00 in New York.
// 10:00 in Los Angeles, after Bob's 12:00-13:00 in New York. The
// requester listed themselves as optional, so the hold does not block it.
slots: [
{
optionalConflicts: [REQUESTER],
otherTimeZoneLabels: ["Mon, Oct 12, 13:00 – 13:30 EDT"],
start: "2026-10-12T17:00:00.000Z",
},
Expand Down
Loading