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
16 changes: 12 additions & 4 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {};
},
Expand Down Expand Up @@ -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 });
Expand Down
59 changes: 59 additions & 0 deletions test/plugin-loader-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});