From 817b20b39e64e38af036473784d18f0c52561404 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 8 Jul 2026 16:15:20 -0500 Subject: [PATCH] fix(models): force-refresh Cursor model catalog on every startup The auth loader warmed the model cache with discoverModels({apiKey}), which respects the 24h on-disk TTL and no-ops when the cache is still fresh. New Cursor models never surfaced locally until the cache happened to expire or a user manually ran cursor_refresh_models. Pass forceRefresh: true from the loader instead. It already runs once per opencode startup with the resolved key and is fire-and-forget, so this triggers a live Cursor.models.list() on every launch without adding startup latency. The config and provider.models hooks still read the existing cache instantly (stale-while-revalidate). --- src/plugin/index.ts | 16 ++++++-- test/plugin-loader-refresh.test.ts | 59 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 test/plugin-loader-refresh.test.ts diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 6eb5ad3..8536d6a 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -72,9 +72,17 @@ export const CursorPlugin: Plugin = async (input) => { // The `config` hook (which seeds opencode's model picker) runs without // a key. Warm the catalog cache here — the loader is the hook that // reliably has the key — so the next launch seeds the full live - // catalog instead of the static fallback. Fire-and-forget: discovery - // never throws and must not block auth/provider load. - void discoverModels({ apiKey }); + // catalog instead of the static fallback. + // + // `forceRefresh: true` bypasses the 24h on-disk cache so a live + // `Cursor.models.list()` runs on every opencode startup. This is the + // stale-while-revalidate write side: the `config` and + // `provider.models` hooks still serve the current cache instantly (no + // startup latency), while this refreshes it in the background so newly + // released Cursor models surface on the next launch instead of waiting + // up to 24h for the cache to expire. Fire-and-forget: discovery never + // throws and must not block auth/provider load. + void discoverModels({ apiKey, forceRefresh: true }); } return apiKey ? { apiKey } : {}; }, @@ -209,7 +217,7 @@ export const CursorPlugin: Plugin = async (input) => { tool: { cursor_refresh_models: { description: - "Refresh the live Cursor model catalog (bypasses the 24h cache) and report the available models.", + "Refresh the live Cursor model catalog now (bypasses the cache) and report the available models. The catalog also auto-refreshes on every opencode startup; use this to pick up new models mid-session.", args: {}, execute: async () => { const result = await discoverModels({ forceRefresh: true }); diff --git a/test/plugin-loader-refresh.test.ts b/test/plugin-loader-refresh.test.ts new file mode 100644 index 0000000..84010a7 --- /dev/null +++ b/test/plugin-loader-refresh.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Spy on model discovery so we can assert HOW the auth loader warms the cache. +// The loader runs once per opencode startup with the resolved key; to surface +// newly released Cursor models it must force a live re-fetch rather than +// respecting the (possibly still-fresh) 24h on-disk cache. +const discoverModels = vi.fn( + async (_options: { apiKey?: string; forceRefresh?: boolean } = {}) => ({ + models: [] as unknown[], + source: "live" as const, + }), +); +vi.mock("../src/model-discovery.js", () => ({ + discoverModels, + toOpencodeModels: () => ({}), +})); + +const { default: plugin } = await import("../src/plugin/index.js"); + +let savedEnvKey: string | undefined; + +beforeEach(() => { + savedEnvKey = process.env.CURSOR_API_KEY; + delete process.env.CURSOR_API_KEY; +}); + +afterEach(() => { + if (savedEnvKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = savedEnvKey; + discoverModels.mockClear(); +}); + +describe("CursorPlugin auth loader model refresh", () => { + it("force-refreshes the model catalog on startup so new models appear", async () => { + const hooks = await plugin({ directory: "/work" } as never); + + const loaded = await hooks.auth!.loader!( + async () => ({ type: "api", key: "sekret" }) as never, + { provider: "cursor" } as never, + ); + expect(loaded).toEqual({ apiKey: "sekret" }); + + // The warm call must bypass the cache: without forceRefresh a fresh 24h + // cache short-circuits discovery and newly released models never surface. + const warmCall = discoverModels.mock.calls.find( + (c) => c[0]?.apiKey === "sekret", + ); + expect(warmCall, "loader should warm discovery with the resolved key").toBeDefined(); + expect(warmCall![0]?.forceRefresh).toBe(true); + }); + + it("does not attempt discovery when no key is resolved", async () => { + const hooks = await plugin({ directory: "/work" } as never); + await hooks.auth!.loader!(async () => undefined as never, { + provider: "cursor", + } as never); + expect(discoverModels).not.toHaveBeenCalled(); + }); +});