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:
@@ -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).
|
||||||
@@ -51,6 +51,32 @@ describe("cursorDiscoveryToModels", () => {
|
|||||||
it("returns an empty array for an empty model list", () => {
|
it("returns an empty array for an empty model list", () => {
|
||||||
expect(cursorDiscoveryToModels([])).toEqual([]);
|
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", () => {
|
describe("getCursorPickerModels caching", () => {
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
* storm. Unlike Hermes (whose profile presence IS the enable signal), Cursor
|
* storm. Unlike Hermes (whose profile presence IS the enable signal), Cursor
|
||||||
* has its own settings toggle (`useCursorCli`); the toggle gate lives in the
|
* has its own settings toggle (`useCursorCli`); the toggle gate lives in the
|
||||||
* `/api/models` merge site (register-model-routes.ts), not in this module.
|
* `/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";
|
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
|
* 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
|
* unique identifier, e.g. `"cursor/gpt-5"`). `name` falls back to `id` when
|
||||||
* no `label` is provided. `reasoning`/`contextWindow` are unknown at this
|
* no `label` is provided.
|
||||||
* layer (Cursor discovery does not report them), so they default to
|
*
|
||||||
* `false`/`0` \u2014 enrichment is a documented follow-up, not part of this
|
* FN-7700: `reasoning`/`contextWindow` are source-driven — when the discovery
|
||||||
* task's scope.
|
* 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
|
* Discovered entries that map to the same id are de-duplicated, keeping the
|
||||||
* first occurrence.
|
* first occurrence.
|
||||||
*/
|
*/
|
||||||
export function cursorDiscoveryToModels(
|
export function cursorDiscoveryToModels(
|
||||||
models: ReadonlyArray<{ id: string; label?: string }>,
|
models: ReadonlyArray<{ id: string; label?: string; reasoning?: boolean; contextWindow?: number }>,
|
||||||
): CursorPickerModel[] {
|
): CursorPickerModel[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const result: CursorPickerModel[] = [];
|
const result: CursorPickerModel[] = [];
|
||||||
@@ -72,8 +82,8 @@ export function cursorDiscoveryToModels(
|
|||||||
provider: CURSOR_PICKER_PROVIDER_ID,
|
provider: CURSOR_PICKER_PROVIDER_ID,
|
||||||
id,
|
id,
|
||||||
name: model.label?.trim() || id,
|
name: model.label?.trim() || id,
|
||||||
reasoning: false,
|
reasoning: model.reasoning ?? false,
|
||||||
contextWindow: 0,
|
contextWindow: model.contextWindow ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,9 +66,16 @@ export async function probeCursorCliProvider(opts?: { binaryPath?: string }): Pr
|
|||||||
return probeCursorBinary(opts);
|
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 {
|
export interface CursorModelDiscoveryResult {
|
||||||
models: Array<{ id: string; label?: string }>;
|
models: Array<{ id: string; label?: string; reasoning?: boolean; contextWindow?: number }>;
|
||||||
source: string;
|
source: string;
|
||||||
fallbackUsed: boolean;
|
fallbackUsed: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
|||||||
@@ -67,6 +67,39 @@ describe("discoverCursorModels", () => {
|
|||||||
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 5000);
|
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models"], 5000);
|
||||||
expect(result.models).toEqual(["cursor/a", "cursor/b"]);
|
expect(result.models).toEqual(["cursor/a", "cursor/b"]);
|
||||||
expect(result.source).toBe("models-json");
|
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 () => {
|
it("returns empty discovery when the command fails outright", async () => {
|
||||||
|
|||||||
@@ -54,4 +54,27 @@ describe("discoverCursorProviderModels", () => {
|
|||||||
reason: "Configured Cursor CLI binary '/missing/cursor-agent' failed; PATH fallback cursor-agent/cursor also failed",
|
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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,32 @@ function parseModelLines(raw: string): string[] {
|
|||||||
return Array.from(new Set(ids));
|
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);
|
const res = await runCursorCommand(binary, ["models"], timeoutMs);
|
||||||
if (res.code !== 0) {
|
if (res.code !== 0) {
|
||||||
return { models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" };
|
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 {
|
try {
|
||||||
const parsed = JSON.parse(output);
|
const parsed = JSON.parse(output);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
const ids = parsed
|
const ids: string[] = [];
|
||||||
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.id === "string" ? entry.id : undefined))
|
const modelMeta: Record<string, CursorModelMeta> = {};
|
||||||
.filter((id): id is string => Boolean(id));
|
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) {
|
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 {
|
} catch {
|
||||||
|
|||||||
@@ -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) {
|
export async function discoverCursorProviderModels(options?: unknown) {
|
||||||
const probe = await probeCursorBinary(normalizeDiscoveryOptions(options));
|
const probe = await probeCursorBinary(normalizeDiscoveryOptions(options));
|
||||||
if (!probe.available || !probe.binaryName) {
|
if (!probe.available || !probe.binaryName) {
|
||||||
@@ -17,7 +26,15 @@ export async function discoverCursorProviderModels(options?: unknown) {
|
|||||||
}
|
}
|
||||||
const result = await discoverCursorModels(probe.binaryPath ?? probe.binaryName);
|
const result = await discoverCursorModels(probe.binaryPath ?? probe.binaryName);
|
||||||
return {
|
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,
|
source: result.source,
|
||||||
fallbackUsed: result.fallbackUsed,
|
fallbackUsed: result.fallbackUsed,
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
|
|||||||
Reference in New Issue
Block a user