diff --git a/src/models/sync.ts b/src/models/sync.ts index 15b3626..edfd8da 100644 --- a/src/models/sync.ts +++ b/src/models/sync.ts @@ -2,9 +2,9 @@ * Non-blocking model auto-refresh for plugin startup. * * Discovers currently available models from cursor-agent and merges them - * into the opencode.json config. Only adds new models — never removes - * user-configured ones. Safe to call fire-and-forget; all errors are - * caught and logged silently. + * into the opencode.json config. Direct mode only adds new models; + * compact mode folds raw variants into OpenCode variants. Safe to call + * fire-and-forget; all errors are caught and logged silently. */ import { existsSync as nodeExistsSync, @@ -14,10 +14,12 @@ import { import { discoverModelsFromCursorAgent, type DiscoveredModel } from "../cli/model-discovery.js"; import { resolveOpenCodeConfigPath } from "../plugin-toggle.js"; import { createLogger, type Logger } from "../utils/logger.js"; +import { mergeCursorModelEntries } from "./variants.js"; const log = createLogger("model-sync"); const PROVIDER_ID = "cursor-acp"; +type AutoRefreshMode = "disabled" | "direct" | "compact"; type ModelConfigEntry = { name: string }; type ProviderConfig = { models?: Record } & Record; type OpenCodeConfig = { @@ -69,17 +71,50 @@ function getExistingModels(provider: ProviderConfig): Record { return isRecord(provider.models) ? { ...provider.models } : {}; } +function readCursorModel(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + const cursorModel = value.cursorModel; + return typeof cursorModel === "string" && cursorModel.trim().length > 0 + ? cursorModel.trim() + : undefined; +} + +function collectRepresentedModelIds(models: Record): Set { + const represented = new Set(Object.keys(models)); + + for (const entry of Object.values(models)) { + if (!isRecord(entry)) continue; + const optionModel = readCursorModel(entry.options); + if (optionModel) represented.add(optionModel); + + if (!isRecord(entry.variants)) continue; + for (const variantEntry of Object.values(entry.variants)) { + const variantModel = readCursorModel(variantEntry); + if (variantModel) represented.add(variantModel); + } + } + + return represented; +} + function yieldForFireAndForget(): Promise { return Promise.resolve(); } +function getAutoRefreshMode(env: NodeJS.ProcessEnv): AutoRefreshMode { + const raw = env.CURSOR_ACP_MODEL_AUTO_REFRESH?.trim().toLowerCase(); + if (raw === "false") return "disabled"; + if (raw === "direct") return "direct"; + return "compact"; +} + /** * Auto-refresh models at plugin startup. * * - Reads the current opencode.json config * - Queries cursor-agent for available models - * - Merges discovered models into the provider config (additive only) - * - Writes back if any new models were added + * - Merges discovered models into the provider config + * - Writes back if new models were added or compacted * * This function never throws. All failures are logged at debug level * and silently ignored so plugin startup is never blocked. @@ -96,6 +131,12 @@ export async function autoRefreshModels( await resolvedDeps.defer(); try { + const refreshMode = getAutoRefreshMode(resolvedDeps.env); + if (refreshMode === "disabled") { + resolvedDeps.log.debug("Model auto-refresh disabled by CURSOR_ACP_MODEL_AUTO_REFRESH"); + return; + } + const configPath = resolveOpenCodeConfigPath(resolvedDeps.env); if (!resolvedDeps.existsSync(configPath)) { resolvedDeps.log.debug("Config file not found, skipping model auto-refresh", { configPath }); @@ -126,14 +167,39 @@ export async function autoRefreshModels( return; } - let addedCount = 0; - for (const model of discovered) { - if (Object.prototype.hasOwnProperty.call(existingModels, model.id)) continue; - existingModels[model.id] = { name: model.name } satisfies ModelConfigEntry; - addedCount++; + if (refreshMode === "direct") { + const existingModelIds = new Set(Object.keys(existingModels)); + const missingModels = discovered.filter(model => !existingModelIds.has(model.id)); + if (missingModels.length === 0) { + resolvedDeps.log.debug("Model auto-refresh: no new models found", { + existing: Object.keys(existingModels).length, + discovered: discovered.length, + }); + return; + } + + const models = { ...existingModels }; + for (const model of missingModels) { + models[model.id] = { name: model.name } satisfies ModelConfigEntry; + } + + provider.models = models; + resolvedDeps.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + resolvedDeps.log.info("Model auto-refresh: added new models", { + added: missingModels.length, + total: Object.keys(models).length, + }); + return; } - if (addedCount === 0) { + const representedModelIds = collectRepresentedModelIds(existingModels); + const missingModels = discovered.filter(model => !representedModelIds.has(model.id)); + const result = mergeCursorModelEntries(existingModels, discovered, { + variants: true, + compact: true, + }); + + if (missingModels.length === 0 && result.removedCount === 0) { resolvedDeps.log.debug("Model auto-refresh: no new models found", { existing: Object.keys(existingModels).length, discovered: discovered.length, @@ -141,11 +207,14 @@ export async function autoRefreshModels( return; } - provider.models = existingModels; + provider.models = result.models; resolvedDeps.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - resolvedDeps.log.info("Model auto-refresh: added new models", { - added: addedCount, - total: Object.keys(existingModels).length, + resolvedDeps.log.info("Model auto-refresh: synced models", { + mode: refreshMode, + synced: result.syncedCount, + grouped: result.groupedCount, + removed: result.removedCount, + total: Object.keys(result.models).length, }); } catch (err) { resolvedDeps.log.debug("Model auto-refresh failed", { error: String(err) }); diff --git a/tests/unit/models/sync.test.ts b/tests/unit/models/sync.test.ts index 0a5b48b..e791ca9 100644 --- a/tests/unit/models/sync.test.ts +++ b/tests/unit/models/sync.test.ts @@ -43,8 +43,13 @@ describe("models/sync", () => { vi.clearAllMocks(); }); - it("adds newly discovered models without removing existing entries", async () => { + it("uses direct sync when explicitly requested", async () => { const { deps, writeFileSync } = createDeps({ + env: { + ...process.env, + OPENCODE_CONFIG: "/tmp/opencode.json", + CURSOR_ACP_MODEL_AUTO_REFRESH: "direct", + }, readFileSync: vi.fn(() => JSON.stringify({ provider: { @@ -77,6 +82,147 @@ describe("models/sync", () => { }); }); + it("uses compact variant sync by default", async () => { + const { deps, writeFileSync } = createDeps({ + readFileSync: vi.fn(() => + JSON.stringify({ + provider: { + "cursor-acp": { + models: { + auto: { name: "Auto" }, + "custom-model": { name: "Custom" }, + "gpt-5.4-low": { name: "Old Low" }, + "gpt-5.4-high": { name: "Old High" }, + }, + }, + }, + }), + ), + discoverModels: vi.fn(() => [ + { id: "auto", name: "Auto" }, + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low" }, + { id: "gpt-5.4-high", name: "GPT-5.4 High" }, + ]), + }); + + await autoRefreshModels(deps); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const [, writtenConfig] = writeFileSync.mock.calls[0]; + const parsed = JSON.parse(writtenConfig as string); + expect(parsed.provider["cursor-acp"].models).toMatchObject({ + auto: { name: "Auto" }, + "custom-model": { name: "Custom" }, + "gpt-5.4": { + name: "GPT-5.4", + options: { cursorModel: "gpt-5.4" }, + variants: { + low: { cursorModel: "gpt-5.4-low" }, + high: { cursorModel: "gpt-5.4-high" }, + }, + }, + }); + expect(parsed.provider["cursor-acp"].models["gpt-5.4-low"]).toBeUndefined(); + expect(parsed.provider["cursor-acp"].models["gpt-5.4-high"]).toBeUndefined(); + }); + + it("compacts existing raw variant entries when no discovered ids are missing", async () => { + const { deps, writeFileSync } = createDeps({ + readFileSync: vi.fn(() => + JSON.stringify({ + provider: { + "cursor-acp": { + models: { + "gpt-5.4": { name: "GPT-5.4" }, + "gpt-5.4-low": { name: "GPT-5.4 Low" }, + "gpt-5.4-high": { name: "GPT-5.4 High" }, + }, + }, + }, + }), + ), + discoverModels: vi.fn(() => [ + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low" }, + { id: "gpt-5.4-high", name: "GPT-5.4 High" }, + ]), + }); + + await autoRefreshModels(deps); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const [, writtenConfig] = writeFileSync.mock.calls[0]; + const parsed = JSON.parse(writtenConfig as string); + expect(parsed.provider["cursor-acp"].models["gpt-5.4"]).toMatchObject({ + options: { cursorModel: "gpt-5.4" }, + variants: { + low: { cursorModel: "gpt-5.4-low" }, + high: { cursorModel: "gpt-5.4-high" }, + }, + }); + expect(parsed.provider["cursor-acp"].models["gpt-5.4-low"]).toBeUndefined(); + expect(parsed.provider["cursor-acp"].models["gpt-5.4-high"]).toBeUndefined(); + }); + + it("uses compact variant sync when explicitly requested", async () => { + const { deps, writeFileSync } = createDeps({ + env: { + ...process.env, + OPENCODE_CONFIG: "/tmp/opencode.json", + CURSOR_ACP_MODEL_AUTO_REFRESH: "compact", + }, + readFileSync: vi.fn(() => + JSON.stringify({ + provider: { + "cursor-acp": { + models: { + "gpt-5.4-low": { name: "Old Low" }, + "gpt-5.4-high": { name: "Old High" }, + }, + }, + }, + }), + ), + discoverModels: vi.fn(() => [ + { id: "gpt-5.4", name: "GPT-5.4" }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low" }, + { id: "gpt-5.4-high", name: "GPT-5.4 High" }, + ]), + }); + + await autoRefreshModels(deps); + + expect(writeFileSync).toHaveBeenCalledTimes(1); + const [, writtenConfig] = writeFileSync.mock.calls[0]; + const parsed = JSON.parse(writtenConfig as string); + expect(parsed.provider["cursor-acp"].models["gpt-5.4"]).toMatchObject({ + options: { cursorModel: "gpt-5.4" }, + variants: { + low: { cursorModel: "gpt-5.4-low" }, + high: { cursorModel: "gpt-5.4-high" }, + }, + }); + expect(parsed.provider["cursor-acp"].models["gpt-5.4-low"]).toBeUndefined(); + expect(parsed.provider["cursor-acp"].models["gpt-5.4-high"]).toBeUndefined(); + }); + + it("can disable startup model refresh", async () => { + const { deps, readFileSync, writeFileSync, discoverModels } = createDeps({ + env: { + ...process.env, + OPENCODE_CONFIG: "/tmp/opencode.json", + CURSOR_ACP_MODEL_AUTO_REFRESH: "false", + }, + }); + + await autoRefreshModels(deps); + + expect(readFileSync).not.toHaveBeenCalled(); + expect(discoverModels).not.toHaveBeenCalled(); + expect(writeFileSync).not.toHaveBeenCalled(); + }); + it("returns silently when the config file is missing", async () => { const { deps, readFileSync, writeFileSync, discoverModels } = createDeps({ existsSync: vi.fn(() => false),