diff --git a/.changeset/fn-7700-cursor-picker-reasoning-context-window.md b/.changeset/fn-7700-cursor-picker-reasoning-context-window.md new file mode 100644 index 0000000000..bdb07b2dcb --- /dev/null +++ b/.changeset/fn-7700-cursor-picker-reasoning-context-window.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Cursor CLI model-picker rows now surface reasoning/context-window metadata when the Cursor CLI reports it. +category: feature +dev: Threads optional reasoning/contextWindow from cursor-agent model discovery (structured JSON entries only) through discoverCursorProviderModels into cursorDiscoveryToModels, replacing the hardcoded false/0 defaults. Text-only CLI output (today's real behavior) still yields false/0, so the change is behavior-preserving against the current CLI and forward-compatible. Metadata is pass-through only — never fabricated or parsed from free text. Parallels the deferred Hermes enrichment gap (FN-7696/FN-7636). diff --git a/packages/dashboard/src/__tests__/cursor-model-cache.test.ts b/packages/dashboard/src/__tests__/cursor-model-cache.test.ts index 41399c96e5..561797c5a2 100644 --- a/packages/dashboard/src/__tests__/cursor-model-cache.test.ts +++ b/packages/dashboard/src/__tests__/cursor-model-cache.test.ts @@ -51,6 +51,32 @@ describe("cursorDiscoveryToModels", () => { it("returns an empty array for an empty model list", () => { expect(cursorDiscoveryToModels([])).toEqual([]); }); + + it("surfaces source-reported reasoning/contextWindow metadata when present", () => { + const models = cursorDiscoveryToModels([ + { id: "cursor/gpt-5", label: "GPT-5", reasoning: true, contextWindow: 200000 }, + ]); + expect(models).toEqual([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: true, contextWindow: 200000 }, + ]); + }); + + it("defaults reasoning/contextWindow to false/0 when the entry does not report them", () => { + const models = cursorDiscoveryToModels([{ id: "cursor/sonnet" }]); + expect(models[0]?.reasoning).toBe(false); + expect(models[0]?.contextWindow).toBe(0); + }); + + it("handles a mix of enriched and default entries independently", () => { + const models = cursorDiscoveryToModels([ + { id: "cursor/a", reasoning: true, contextWindow: 128000 }, + { id: "cursor/b" }, + ]); + expect(models).toEqual([ + { provider: "cursor-cli", id: "cursor/a", name: "cursor/a", reasoning: true, contextWindow: 128000 }, + { provider: "cursor-cli", id: "cursor/b", name: "cursor/b", reasoning: false, contextWindow: 0 }, + ]); + }); }); describe("getCursorPickerModels caching", () => { diff --git a/packages/dashboard/src/cursor-model-cache.ts b/packages/dashboard/src/cursor-model-cache.ts index c3ee059675..4cfa9cd874 100644 --- a/packages/dashboard/src/cursor-model-cache.ts +++ b/packages/dashboard/src/cursor-model-cache.ts @@ -25,6 +25,13 @@ * storm. Unlike Hermes (whose profile presence IS the enable signal), Cursor * has its own settings toggle (`useCursorCli`); the toggle gate lives in the * `/api/models` merge site (register-model-routes.ts), not in this module. + * + * FN-7700: `reasoning`/`contextWindow` are now source-driven, not hardcoded. + * `cursorDiscoveryToModels` reads `entry.reasoning`/`entry.contextWindow` + * when the discovery pipeline reports them (structured JSON `cursor-agent` + * output only — the real, plain-text CLI never reports them today), and + * falls back to `false`/`0` otherwise. This mirrors the still-deferred + * Hermes enrichment gap noted below; do not change Hermes behavior here. */ import { discoverCursorCliModels } from "./runtime-provider-probes.js"; @@ -49,16 +56,19 @@ const DEFAULT_TTL_MS = 60_000; * * The discovered `id` is used as the stable model id (it is the CLI's own * unique identifier, e.g. `"cursor/gpt-5"`). `name` falls back to `id` when - * no `label` is provided. `reasoning`/`contextWindow` are unknown at this - * layer (Cursor discovery does not report them), so they default to - * `false`/`0` \u2014 enrichment is a documented follow-up, not part of this - * task's scope. + * no `label` is provided. + * + * FN-7700: `reasoning`/`contextWindow` are source-driven — when the discovery + * entry reports them (structured JSON `cursor-agent` output only), they are + * carried through verbatim; otherwise they default to `false`/`0`, exactly + * as before. This is pass-through only: never fabricated, never parsed from + * free-text labels. * * Discovered entries that map to the same id are de-duplicated, keeping the * first occurrence. */ export function cursorDiscoveryToModels( - models: ReadonlyArray<{ id: string; label?: string }>, + models: ReadonlyArray<{ id: string; label?: string; reasoning?: boolean; contextWindow?: number }>, ): CursorPickerModel[] { const seen = new Set(); const result: CursorPickerModel[] = []; @@ -72,8 +82,8 @@ export function cursorDiscoveryToModels( provider: CURSOR_PICKER_PROVIDER_ID, id, name: model.label?.trim() || id, - reasoning: false, - contextWindow: 0, + reasoning: model.reasoning ?? false, + contextWindow: model.contextWindow ?? 0, }); } diff --git a/packages/dashboard/src/runtime-provider-probes.ts b/packages/dashboard/src/runtime-provider-probes.ts index 5538822fe7..797db6eb88 100644 --- a/packages/dashboard/src/runtime-provider-probes.ts +++ b/packages/dashboard/src/runtime-provider-probes.ts @@ -66,9 +66,16 @@ export async function probeCursorCliProvider(opts?: { binaryPath?: string }): Pr return probeCursorBinary(opts); } -/** Result shape returned by the Cursor plugin's model-discovery contribution. */ +/** + * Result shape returned by the Cursor plugin's model-discovery contribution. + * + * FNXC:CursorCli 2026-07-08-00:00: + * FN-7700: `reasoning`/`contextWindow` are optional pass-through fields the + * plugin only populates from structured (JSON) discovery entries; they are + * omitted (never defaulted here) when the source did not report them. + */ export interface CursorModelDiscoveryResult { - models: Array<{ id: string; label?: string }>; + models: Array<{ id: string; label?: string; reasoning?: boolean; contextWindow?: number }>; source: string; fallbackUsed: boolean; reason?: string; diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts index 1e77e839b7..e134ac7fdc 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts @@ -67,6 +67,39 @@ describe("discoverCursorModels", () => { expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 5000); expect(result.models).toEqual(["cursor/a", "cursor/b"]); expect(result.source).toBe("models-json"); + expect(result.modelMeta).toBeUndefined(); + }); + + it("captures reasoning/contextWindow metadata from JSON object entries that report them", async () => { + vi.mocked(runCursorCommand).mockResolvedValueOnce({ + code: 0, + stdout: '[{"id":"cursor/a","reasoning":true,"contextWindow":200000},{"id":"cursor/b"}]', + stderr: "", + }); + const result = await discoverCursorModels("cursor-agent"); + + expect(result.models).toEqual(["cursor/a", "cursor/b"]); + expect(result.modelMeta).toEqual({ "cursor/a": { reasoning: true, contextWindow: 200000 } }); + }); + + it("ignores malformed metadata field types on JSON object entries", async () => { + vi.mocked(runCursorCommand).mockResolvedValueOnce({ + code: 0, + stdout: '[{"id":"cursor/a","reasoning":"yes","contextWindow":"big"}]', + stderr: "", + }); + const result = await discoverCursorModels("cursor-agent"); + + expect(result.models).toEqual(["cursor/a"]); + expect(result.modelMeta).toBeUndefined(); + }); + + it("never populates modelMeta from the plain-text discovery path", async () => { + vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: REAL_MODELS_OUTPUT, stderr: "" }); + const result = await discoverCursorModels("cursor-agent"); + + expect(result.source).toBe("models-text"); + expect(result.modelMeta).toBeUndefined(); }); it("returns empty discovery when the command fails outright", async () => { diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts index f2159a58ed..9db3c04535 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts @@ -54,4 +54,27 @@ describe("discoverCursorProviderModels", () => { reason: "Configured Cursor CLI binary '/missing/cursor-agent' failed; PATH fallback cursor-agent/cursor also failed", }); }); + + it("carries reasoning/contextWindow metadata through when the discovery result reports it", async () => { + vi.mocked(probeCursorBinary).mockResolvedValue({ + available: true, + authenticated: true, + binaryName: "cursor-agent", + binaryPath: "cursor-agent", + probeDurationMs: 5, + }); + vi.mocked(discoverCursorModels).mockResolvedValue({ + models: ["cursor/a", "cursor/b"], + source: "models-json", + fallbackUsed: false, + modelMeta: { "cursor/a": { reasoning: true, contextWindow: 200000 } }, + }); + + const result = await discoverCursorProviderModels(); + + expect(result.models).toEqual([ + { id: "cursor/a", label: "cursor/a", reasoning: true, contextWindow: 200000 }, + { id: "cursor/b", label: "cursor/b" }, + ]); + }); }); diff --git a/plugins/fusion-plugin-cursor-runtime/src/process-manager.ts b/plugins/fusion-plugin-cursor-runtime/src/process-manager.ts index bcfa603170..ba6add993c 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/process-manager.ts @@ -30,7 +30,32 @@ function parseModelLines(raw: string): string[] { return Array.from(new Set(ids)); } -export async function discoverCursorModels(binary: string, timeoutMs = 5000): Promise<{ models: string[]; source: string; fallbackUsed: boolean; reason?: string }> { +/** Optional per-model metadata captured only from structured (JSON) discovery entries. */ +export interface CursorModelMeta { + reasoning?: boolean; + contextWindow?: number; +} + +export interface CursorModelDiscoveryResult { + models: string[]; + source: string; + fallbackUsed: boolean; + reason?: string; + /** + * FNXC:CursorCli 2026-07-08-00:00: + * FN-7700: optional per-id `reasoning`/`contextWindow` metadata, populated + * ONLY when the defensive JSON-tolerant discovery path parses object + * entries carrying those fields. The real, plain-text `cursor-agent models` + * output (source `models-text`/`none`) never populates this map — metadata + * is structured-source pass-through only, never parsed from the free-text + * ` -