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", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user