Calendar tool reads refresh token from GoogleTokenStore per call

This commit is contained in:
EugeneTes
2026-09-23 10:07:50 +00:00
parent f765784acb
commit ecf61c34e1
2 changed files with 136 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleCalendar } from "./calendar.js";
import { createCalendarTool, handleCalendar } from "./calendar.js";
import type { GoogleTokenStore } from "./google-token-store.js";
type FetchCall = { url: string; init?: RequestInit };
@@ -338,3 +339,106 @@ describe("calendar tool", () => {
).rejects.toThrow(/list_instances requires event_id/);
});
});
function memoryStore(initial: string | null): GoogleTokenStore & { current: string | null } {
const state = { current: initial };
return {
read: () => state.current,
write: (t: string) => {
state.current = t;
},
get current() {
return state.current;
},
set current(v: string | null) {
state.current = v;
},
};
}
async function runTool(tool: ReturnType<typeof createCalendarTool>, params: unknown): Promise<string> {
const res = await tool.execute("tc-1", params as never);
return (res.content[0] as { text: string }).text;
}
describe("createCalendarTool store integration", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns a 'not connected' message when the store has no refresh token", async () => {
globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
const tool = createCalendarTool({
clientId: "cid",
clientSecret: "csecret",
tokenStore: memoryStore(null),
timezone: "UTC",
});
await expect(runTool(tool, { action: "list" })).rejects.toThrow(/not connected/i);
await expect(runTool(tool, { action: "list" })).rejects.toThrow(/\/connect_calendar/);
});
it("mints an access token using the store's refresh token, then reuses the cached one", async () => {
const { fn, calls } = stubFetch([
{ body: { access_token: "at-1", expires_in: 3600 } },
{ body: { items: [] } },
{ body: { items: [] } },
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const tool = createCalendarTool({
clientId: "cid",
clientSecret: "csecret",
tokenStore: memoryStore("rt-1"),
timezone: "UTC",
});
await runTool(tool, { action: "list" });
await runTool(tool, { action: "list" });
// First call = token refresh, second and third = list events (cached token).
expect(calls[0].url).toBe("https://oauth2.googleapis.com/token");
expect(calls[1].url).toContain("/calendars/primary/events");
expect(calls[2].url).toContain("/calendars/primary/events");
const tokenBody = new URLSearchParams(calls[0].init!.body as string);
expect(tokenBody.get("refresh_token")).toBe("rt-1");
});
it("drops the cached access token when the stored refresh token changes", async () => {
const store = memoryStore("rt-1");
const { fn, calls } = stubFetch([
{ body: { access_token: "at-1", expires_in: 3600 } },
{ body: { items: [] } },
{ body: { access_token: "at-2", expires_in: 3600 } },
{ body: { items: [] } },
]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const tool = createCalendarTool({
clientId: "cid",
clientSecret: "csecret",
tokenStore: store,
timezone: "UTC",
});
await runTool(tool, { action: "list" });
store.current = "rt-2";
await runTool(tool, { action: "list" });
// Two refresh calls, two list calls, interleaved.
expect(calls[0].url).toBe("https://oauth2.googleapis.com/token");
expect(calls[2].url).toBe("https://oauth2.googleapis.com/token");
const secondRefresh = new URLSearchParams(calls[2].init!.body as string);
expect(secondRefresh.get("refresh_token")).toBe("rt-2");
});
it("invalid_grant returns the /connect_calendar message", async () => {
const { fn } = stubFetch([{ status: 400, body: { error: "invalid_grant" } }]);
globalThis.fetch = fn as unknown as typeof globalThis.fetch;
const tool = createCalendarTool({
clientId: "cid",
clientSecret: "csecret",
tokenStore: memoryStore("rt-stale"),
timezone: "UTC",
});
await expect(runTool(tool, { action: "list" })).rejects.toThrow(/\/connect_calendar/);
});
});

View File

@@ -1,6 +1,6 @@
import { defineTool } from "@earendil-works/pi-coding-agent";
import { type Static, Type } from "typebox";
import type { GoogleConfig } from "../config.js";
import type { GoogleTokenStore } from "./google-token-store.js";
export const calendarParams = Type.Object({
action: Type.Union(
@@ -87,19 +87,36 @@ function eventUrl(calId: string, eventId: string): string {
return `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(eventId)}`;
}
export function createCalendarTool(google: GoogleConfig, timezone: string) {
let cachedToken: { access: string; expiresAt: number } | null = null;
export interface CalendarDeps {
clientId: string;
clientSecret: string;
tokenStore: GoogleTokenStore;
timezone: string;
}
export function createCalendarTool(deps: CalendarDeps) {
let cachedToken: { access: string; expiresAt: number; refreshToken: string } | null = null;
const getAccessToken = async (): Promise<string> => {
const refreshToken = deps.tokenStore.read();
if (!refreshToken) {
throw new Error("Google Calendar is not connected. Run /connect_calendar in the bot to link your Google account.");
}
const now = Date.now();
if (cachedToken && cachedToken.expiresAt > now + 60_000) return cachedToken.access;
if (
cachedToken &&
cachedToken.refreshToken === refreshToken &&
cachedToken.expiresAt > now + 60_000
) {
return cachedToken.access;
}
const resp = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: google.clientId,
client_secret: google.clientSecret,
refresh_token: google.refreshToken,
client_id: deps.clientId,
client_secret: deps.clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
@@ -107,13 +124,17 @@ export function createCalendarTool(google: GoogleConfig, timezone: string) {
const text = await resp.text();
if (text.includes("invalid_grant")) {
throw new Error(
"Google refresh token was rejected (invalid_grant). Re-run the device-flow bootstrap and update GOOGLE_REFRESH_TOKEN.",
"Google refresh token was rejected (invalid_grant). Run /connect_calendar in the bot to relink.",
);
}
throw new Error(`Google token refresh failed: ${resp.status} ${text}`);
}
const data = (await resp.json()) as { access_token: string; expires_in: number };
cachedToken = { access: data.access_token, expiresAt: now + data.expires_in * 1000 };
cachedToken = {
access: data.access_token,
expiresAt: now + data.expires_in * 1000,
refreshToken,
};
return data.access_token;
};
@@ -125,7 +146,7 @@ export function createCalendarTool(google: GoogleConfig, timezone: string) {
promptSnippet: "Read and modify the owner's Google Calendar",
parameters: calendarParams,
execute: async (_toolCallId, params) => {
const text = await handleCalendar(params, timezone, getAccessToken);
const text = await handleCalendar(params, deps.timezone, getAccessToken);
return { content: [{ type: "text", text }], details: {} };
},
});