FN-7700: enrich Cursor model picker with reasoning/contextWindow metadata

Threads optional reasoning and context-window metadata from Cursor CLI model discovery through to the dashboard's Cursor model picker, replacing hardcoded false/0 defaults with pass-through values when the CLI reports them.
- Extend cursorDiscoveryToModels/discoverCursorProviderModels to carry reasoning/contextWindow from structured JSON model entries
- Update runtime-provider-probes.ts to surface the new metadata fields
- Update cursor-agent process-manager and provider to parse and propagate reasoning/contextWindow from CLI output
- Add/extend tests covering the new metadata plumbing in cursor-model-cache, process-manager, and provider
- Add changeset documenting the patch-level dashboard feature

Files changed:
 .changeset/fn-7700-cursor-picker-reasoning-context-window.md      |  7 ++++
 packages/dashboard/src/__tests__/cursor-model-cache.test.ts       | 26 ++++++++++++
 packages/dashboard/src/cursor-model-cache.ts                      | 24 +++++++----
 packages/dashboard/src/runtime-provider-probes.ts                 | 11 ++++-
 plugins/fusion-plugin-cursor-runtime/src/__tests__/process-manager.test.ts | 33 +++++++++++++++
 plugins/fusion-plugin-cursor-runtime/src/__tests__/provider.test.ts       | 23 +++++++++++
 plugins/fusion-plugin-cursor-runtime/src/process-manager.ts               | 48 +++++++++++++++++++---
 plugins/fusion-plugin-cursor-runtime/src/provider.ts                      | 19 ++++++++-
 8 files changed, 176 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-7700

Fusion-Task-Lineage: 6b371a92-204a-4201-8c7d-df65e9210a1b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 18:23:45 -07:00
parent 639a706f12
commit 3e7e4a865b
8 changed files with 176 additions and 15 deletions

View File

@@ -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).

View File

@@ -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", () => {

View File

@@ -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<string>();
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,
});
}

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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" },
]);
});
});

View File

@@ -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
* `<id> - <Label>` lines and never fabricated. Additive alongside the
* existing `models: string[]` bare-id contract; omitted entirely when no
* entry carried any metadata.
*/
modelMeta?: Record<string, CursorModelMeta>;
}
export async function discoverCursorModels(binary: string, timeoutMs = 5000): Promise<CursorModelDiscoveryResult> {
const res = await runCursorCommand(binary, ["models"], timeoutMs);
if (res.code !== 0) {
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
@@ -50,11 +75,24 @@ export async function discoverCursorModels(binary: string, timeoutMs = 5000): Pr
try {
const parsed = JSON.parse(output);
if (Array.isArray(parsed)) {
const ids = parsed
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined))
.filter((id): id is string => Boolean(id));
const ids: string[] = [];
const modelMeta: Record<string, CursorModelMeta> = {};
for (const entry of parsed) {
const id = typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined;
if (!id) continue;
ids.push(id);
if (entry && typeof entry === "object") {
const meta: CursorModelMeta = {};
if (typeof entry.reasoning === "boolean") meta.reasoning = entry.reasoning;
if (typeof entry.contextWindow === "number") meta.contextWindow = entry.contextWindow;
if (Object.keys(meta).length > 0) modelMeta[id] = meta;
}
}
if (ids.length > 0) {
return { models: Array.from(new Set(ids)), source: "models-json", fallbackUsed: false };
const result: CursorModelDiscoveryResult = { models: Array.from(new Set(ids)), source: "models-json", fallbackUsed: false };
if (Object.keys(modelMeta).length > 0) result.modelMeta = modelMeta;
return result;
}
}
} catch {

View File

@@ -10,6 +10,15 @@ function normalizeDiscoveryOptions(options?: unknown): { binaryPath?: string; ti
};
}
/*
FNXC:CursorCli 2026-07-08-00:00:
FN-7700: `reasoning`/`contextWindow` are carried through from the plugin's
`discoverCursorModels` `modelMeta` map (structured JSON discovery entries
only) into each returned `{ id, label, reasoning?, contextWindow? }` entry.
They are omitted (never defaulted here) when the discovery result did not
report them for a given id — pass-through only, never fabricated or parsed
from the plain-text `<id> - <Label>` output.
*/
export async function discoverCursorProviderModels(options?: unknown) {
const probe = await probeCursorBinary(normalizeDiscoveryOptions(options));
if (!probe.available || !probe.binaryName) {
@@ -17,7 +26,15 @@ export async function discoverCursorProviderModels(options?: unknown) {
}
const result = await discoverCursorModels(probe.binaryPath ?? probe.binaryName);
return {
models: result.models.map((id) => ({ id, label: id })),
models: result.models.map((id) => {
const meta = result.modelMeta?.[id];
return {
id,
label: id,
...(meta?.reasoning !== undefined ? { reasoning: meta.reasoning } : {}),
...(meta?.contextWindow !== undefined ? { contextWindow: meta.contextWindow } : {}),
};
}),
source: result.source,
fallbackUsed: result.fallbackUsed,
reason: result.reason,