[models] Discover Cursor picker models from CLI (#3624)

* feat(models): discover Cursor picker models from CLI

Replace the generated Cursor base-model catalog with live cursor-agent models discovery on the bound runner. Normalize compound effort variants and legacy dotted Claude spellings into the base-id namespace used by launch, /model switching, and terminal mirroring.

Route the discovered options through the existing session model-options cache, return retryable failures without blocking Cursor launch, and expose the same live listing to model-catalog callers. Remove the obsolete generator and eleven Cursor hardcode baseline entries.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(models): preserve valid Cursor picker options

Carry forward round-trip protection without restoring the stale generated denylist. Skip and log reversed Claude ids that the Cursor command parser cannot inject, while retaining CLI-advertised models verified against the current agent.

Keep cached picker options visible during asynchronous refreshes so model or effort changes do not transiently blank Cursor's picker. Cover both the parser guard and refresh behavior with regression tests.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(models): verify exact Cursor picker matches

Resolve the selected model's display name from the same live CLI catalog that supplies the Web picker, then refuse to press Enter unless Cursor highlights that exact row. This prevents fuzzy matching from silently selecting another model without restoring release-specific denylist entries.

Keep default/current tags unique in catalog order and limit stale-option retention to Cursor refreshes, preserving the existing drop-on-refresh behavior for Codex and other runner-backed pickers.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(cursor): reuse live model metadata when switching

Cache Cursor model display names when the runner serves the live picker catalog and pass the selected label into the TUI bridge. This avoids spawning cursor-agent models a second time for the same selection while retaining live discovery as a cold-cache fallback.

Also require the highlighted picker row to match the complete normalized display label or one of its suffixed variants, preventing similarly prefixed models from being accepted. Clear the cache with the existing session and agent lifecycle caches and cover cached, cold-cache, and fuzzy-match behavior.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* refactor(cursor): drop unused parser binding

Keep the model-option setdefault call for deduplication without assigning its return value before the later result loop. This addresses the code-quality finding without changing parser behavior.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

* fix(cursor): handle missing CLI during model switch

Catch click.ClickException while refreshing a cold Cursor model catalog so a missing cursor-agent executable becomes the existing handled RuntimeError instead of escaping the runner endpoint as a 500.

Add bridge-level regression coverage for the preserved exception cause. The focused Cursor/native-event suite passes 122 tests and full pre-commit passes.

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
This commit is contained in:
Pat Sukprasert
2026-07-31 12:02:08 +07:00
committed by GitHub
parent 0bc1cbe992
commit e16056b2f0
16 changed files with 618 additions and 495 deletions
-11
View File
@@ -18,17 +18,6 @@
.github/workflows/security-triage.yml databricks-claude-sonnet-4-6 1
.github/workflows/vscode-release-pr.yml databricks-claude-sonnet-4-6 1
omnigent/cli_config.py claude-opus-4-5-20251101-v1:0 1
omnigent/cursor_native.py claude-opus-4-5 1
omnigent/cursor_native.py claude-opus-4-6 1
omnigent/cursor_native.py claude-opus-4-7 1
omnigent/cursor_native.py claude-opus-4-8 1
omnigent/cursor_native.py claude-sonnet-4-5 1
omnigent/cursor_native.py claude-sonnet-4-6 1
omnigent/cursor_native.py gpt-5.2 1
omnigent/cursor_native.py gpt-5.2-codex 1
omnigent/cursor_native.py gpt-5.3-codex 1
omnigent/cursor_native.py gpt-5.4 1
omnigent/cursor_native.py gpt-5.5 1
omnigent/inner/pi_executor.py databricks-claude-opus-4-8 1
omnigent/inner/pi_executor.py databricks-claude-sonnet-4-5 1
omnigent/inner/pi_executor.py databricks-claude-sonnet-4-6 1
+83 -64
View File
@@ -16,9 +16,11 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import shutil
import subprocess
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
@@ -58,6 +60,8 @@ from omnigent.native_terminal import (
)
from omnigent.native_terminal import url_component
_logger = logging.getLogger(__name__)
_DEFAULT_CURSOR_COMMAND = "cursor-agent"
_CURSOR_PATH_ENV = "OMNIGENT_CURSOR_PATH"
#: cursor chat ids (used as ``external_session_id``) are canonical UUIDs, e.g.
@@ -202,74 +206,89 @@ def _inject_mode_arg(
return ("--mode", mode, *cursor_args)
# Catalog of cursor-agent *base* models, in web-picker display order.
#
# cursor exposes three incompatible model namespaces and the picker must use the
# one shared by all the surfaces we drive:
#
# * ``cursor-agent models`` / ``--list-models`` flattens (model x effort) into
# ~80 compound ids (``gpt-5.2-high``, ``claude-4.6-opus-high``) and spells
# the claude 4.5/4.6 family with reversed word order + a dotted version
# (``claude-4.6-opus-*`` vs the canonical ``claude-opus-4-6``); 4.7/4.8 use
# the canonical form.
# * The interactive ``/model`` picker filters by base id / display name and
# keeps effort/context on a separate "Tab to modify" axis — typing a
# compound id like ``claude-4.6-opus-high`` yields "No matches".
# * The persisted selection (chat ``meta.lastUsedModel`` / ``cli-config``)
# uses the *base* id (``claude-opus-4-6``, ``gpt-5.2``).
#
# The base-id namespace is the only one that round-trips across all three
# surfaces — each id below selects the right model via ``--model`` (launch),
# ``/model <id>`` (live inject), and is what ``meta.lastUsedModel`` reports back
# (mirror). The list below is DERIVED from ``cursor-agent models`` by
# ``scripts/gen_cursor_models.py``: it strips the effort/thinking suffix to
# recover the base id, applies a small override map for the irregular claude
# spellings, and drops prefix-collision / unoffered tiers (e.g. ``gpt-5.1``
# mis-ranks to "Codex 5.1 Max"). Re-run that script when cursor ships models and
# paste its output between the markers below; review the diff (a new irregular
# claude spelling needs a one-line override and the script warns about it).
#
# >>> generated by scripts/gen_cursor_models.py — do not edit by hand
_CURSOR_BASE_MODELS: list[dict[str, Any]] = [
{"id": "auto", "displayName": "Auto"},
{"id": "composer-2.5", "displayName": "Composer 2.5", "isDefault": True},
{"id": "claude-opus-4-8", "displayName": "Opus 4.8"},
{"id": "claude-opus-4-7", "displayName": "Opus 4.7"},
{"id": "claude-opus-4-6", "displayName": "Opus 4.6"},
{"id": "claude-opus-4-5", "displayName": "Opus 4.5"},
{"id": "claude-sonnet-4-6", "displayName": "Sonnet 4.6"},
{"id": "claude-sonnet-4-5", "displayName": "Sonnet 4.5"},
{"id": "gpt-5.5", "displayName": "GPT-5.5"},
{"id": "gpt-5.4", "displayName": "GPT-5.4"},
{"id": "gpt-5.2", "displayName": "GPT-5.2"},
{"id": "gpt-5.3-codex", "displayName": "Codex 5.3"},
{"id": "gpt-5.2-codex", "displayName": "Codex 5.2"},
{"id": "gemini-3.1-pro", "displayName": "Gemini 3.1 Pro"},
]
# <<< generated
_CURSOR_MODEL_LINE_RE = re.compile(r"^(?P<id>\S+)\s+-\s+(?P<name>.+?)(?:\s+\((?P<tags>[^)]*)\))?$")
_CURSOR_VARIANT_SUFFIX_RE = re.compile(
r"(?:-(?:extra-high|thinking|xhigh|medium|high|low|none|max|fast))+$"
)
_CURSOR_DOTTED_CLAUDE_RE = re.compile(
r"^claude-(?P<major>\d+)\.(?P<minor>\d+)-(?P<family>[a-z][a-z0-9-]*)$"
)
_CURSOR_UNMAPPED_CLAUDE_RE = re.compile(r"^claude-\d+(?:\.\d+)?-[a-z][a-z0-9-]*$")
_CURSOR_DISPLAY_SUFFIXES = frozenset(
{"1m", "none", "low", "medium", "high", "max", "thinking", "extra", "fast"}
)
def cursor_base_model_options() -> list[dict[str, Any]]:
"""
Return the curated cursor-agent base-model options for the Web UI picker.
def _cursor_base_model_id(compound_id: str) -> str:
"""Normalize a Cursor model/variant id to its injectable base id."""
base_id = _CURSOR_VARIANT_SUFFIX_RE.sub("", compound_id)
dotted_claude = _CURSOR_DOTTED_CLAUDE_RE.fullmatch(base_id)
if dotted_claude is None:
return base_id
return "claude-{family}-{major}-{minor}".format(**dotted_claude.groupdict())
Each option carries ``id`` (the base model id — see
:data:`_CURSOR_BASE_MODELS`), ``displayName``, and ``isDefault``/``isCurrent``
flags. The ids match what ``/model`` accepts and what ``meta.lastUsedModel``
reports, so the picker selection round-trips through launch, live switch,
and the terminal→web mirror.
:returns: Fresh option dicts (callers may mutate); base order preserved.
"""
return [
{
"id": m["id"],
"displayName": m["displayName"],
"isDefault": bool(m.get("isDefault", False)),
"isCurrent": False,
}
for m in _CURSOR_BASE_MODELS
]
def _cursor_base_display_name(display_name: str) -> str:
"""Remove variant labels from a Cursor model display name."""
words = display_name.split()
while words and words[-1].lower() in _CURSOR_DISPLAY_SUFFIXES:
words.pop()
return " ".join(words)
def parse_cursor_cli_model_options(output: str) -> list[dict[str, Any]]:
"""Parse ``cursor-agent models`` output into base-model picker rows."""
options_by_id: dict[str, dict[str, Any]] = {}
default_model_id: str | None = None
current_model_id: str | None = None
for raw_line in output.splitlines():
match = _CURSOR_MODEL_LINE_RE.fullmatch(raw_line.strip())
if match is None:
continue
model_id = _cursor_base_model_id(match.group("id"))
if _CURSOR_UNMAPPED_CLAUDE_RE.fullmatch(model_id):
_logger.warning("Skipping non-injectable Cursor model id %r", model_id)
continue
display_name = _cursor_base_display_name(match.group("name")) or model_id
tags = {tag.strip().lower() for tag in (match.group("tags") or "").split(",")}
options_by_id.setdefault(
model_id,
{
"id": model_id,
"displayName": display_name,
"isDefault": False,
"isCurrent": False,
},
)
if "default" in tags and default_model_id is None:
default_model_id = model_id
if "current" in tags and current_model_id is None:
current_model_id = model_id
options = list(options_by_id.values())
if not options:
raise ValueError("cursor-agent model list did not contain any valid models")
for option in options:
option["isDefault"] = option["id"] == default_model_id
option["isCurrent"] = option["id"] == current_model_id
return options
def list_cursor_cli_model_options(
*,
env: Mapping[str, str] | None = None,
timeout_s: float = 10.0,
) -> list[dict[str, Any]]:
"""Discover base-model picker options from the installed Cursor CLI."""
executable = resolve_cursor_executable(env=env)
completed = subprocess.run(
[executable, "models"],
check=True,
capture_output=True,
text=True,
timeout=timeout_s,
env=dict(env) if env is not None else None,
)
return parse_cursor_cli_model_options(completed.stdout)
def run_cursor_native(
+59 -3
View File
@@ -23,6 +23,8 @@ import time
from pathlib import Path
from typing import Any
import click
from omnigent._platform import stable_user_id
#: Env var carrying the bridge dir into the harness executor process.
@@ -739,6 +741,7 @@ def inject_model_command(
bridge_dir: Path,
*,
model: str,
expected_display_name: str | None = None,
timeout_s: float = _TMUX_READY_TIMEOUT_S,
) -> None:
"""Switch the live Cursor model by driving the TUI ``/model`` picker.
@@ -752,8 +755,10 @@ def inject_model_command(
the cursor analog of claude-native's ``inject_slash_command('/model …')``.
:param bridge_dir: The cursor-native bridge dir holding ``tmux.json``.
:param model: cursor-agent model id, e.g. ``"gpt-5.2"`` (the same ids
``cursor-agent --list-models`` reports).
:param model: cursor-agent base model id, e.g. ``"gpt-5.2"`` (derived from
``cursor-agent models`` by stripping effort variants).
:param expected_display_name: Display name from the already-fetched live
picker catalog. ``None`` refreshes the catalog before switching.
:param timeout_s: Per-readiness-gate timeout.
:raises RuntimeError: If the tmux target is never advertised, the TUI has
exited, a tmux command fails, or the picker reports no match for *model*
@@ -763,6 +768,19 @@ def inject_model_command(
model = model.strip()
if not model:
raise RuntimeError("cursor-native model switch requires a non-empty model id")
if expected_display_name is None:
try:
expected_display_name = next(
option["displayName"]
for option in _live_cursor_model_options()
if option.get("id") == model
)
except (click.ClickException, OSError, subprocess.SubprocessError, ValueError) as exc:
raise RuntimeError("cursor-native could not verify the live model catalog") from exc
except StopIteration as exc:
raise RuntimeError(
f"cursor model {model!r} is not available in the live catalog"
) from exc
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
socket_path = info["socket_path"]
tmux_target = info["tmux_target"]
@@ -794,7 +812,8 @@ def inject_model_command(
time.sleep(_MODEL_PICKER_SETTLE_S)
# Re-read after the settle: a transient "No matches" can flash mid-filter,
# and a real match may only resolve once the debounce fires.
if _PICKER_NO_MATCH_MARKER in _capture_pane(socket_path, tmux_target):
settled_pane = _capture_pane(socket_path, tmux_target)
if _PICKER_NO_MATCH_MARKER in settled_pane:
# Dismiss the picker and clear the composer so the literal "/model <id>"
# can't be submitted as a chat message, then fail loudly so the web
# surfaces an honest error instead of silently selecting nothing.
@@ -804,9 +823,46 @@ def inject_model_command(
f"cursor model {model!r} is not available in the picker (no match); "
"the model was not switched"
)
highlighted_row = _picker_highlighted_row(settled_pane)
if (
_PICKER_MATCH_MARKER not in settled_pane
or highlighted_row is None
or not _picker_row_matches_display(highlighted_row, expected_display_name)
):
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Escape")
_clear_composer(socket_path, tmux_target)
raise RuntimeError(
f"cursor model {model!r} did not resolve to its exact picker row; "
"the model was not switched"
)
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Enter")
def _live_cursor_model_options() -> list[dict[str, Any]]:
"""Read the same live catalog that supplies Cursor's Web picker."""
from omnigent.cursor_native import list_cursor_cli_model_options
return list_cursor_cli_model_options()
def _picker_highlighted_row(pane: str) -> str | None:
"""Return the text of Cursor's currently highlighted picker row."""
for line in pane.splitlines():
stripped = line.strip()
if stripped.startswith(""):
return stripped.removeprefix("").strip()
return None
def _picker_row_matches_display(row: str, display_name: str) -> bool:
"""Match a base display name without accepting a longer-name prefix."""
normalized_row = " ".join(row.casefold().split())
normalized_display = " ".join(display_name.casefold().split())
return normalized_row == normalized_display or normalized_row.startswith(
f"{normalized_display} "
)
def _wait_for_pane_settle(socket_path: str, tmux_target: str, *, timeout_s: float) -> None:
"""Best-effort wait until the pane stops changing across two captures.
+3 -3
View File
@@ -509,9 +509,9 @@ def _read_last_used_model(store_path: Path) -> str | None:
cursor records the active model in the ``meta`` table under key ``"0"`` as a
hex-encoded JSON blob carrying ``lastUsedModel`` — the *base* model id (e.g.
``"gpt-5.2"``, ``"claude-opus-4-6"``), the same namespace the curated picker
catalog (:func:`omnigent.cursor_native.cursor_base_model_options`) and the
``/model`` picker use, so a mirrored value matches a picker option. It
``"gpt-5.2"``, ``"claude-opus-4-6"``), the same namespace the live CLI
catalog parser and the ``/model`` picker use, so a mirrored value matches a
picker option. It
updates in place whenever the user switches model in the TUI, so polling it
is how the web picker learns of a terminal-side switch (the reverse of
:func:`omnigent.cursor_native_bridge.inject_model_command`).
+35 -16
View File
@@ -19,9 +19,8 @@ Enumeration is deterministic per provider kind:
- ``key`` (openai family) / ``gateway`` / ``local`` →
``GET <base_url>/v1/models`` with a bearer token (source
``"openai-compatible"``).
- ``subscription`` → a curated static list (source ``"static"``,
``verified: false`` — CLI logins expose no listing API). The cursor
harnesses always resolve here: cursor-agent brings its own login.
- ``subscription`` → live CLI discovery for Cursor; curated static aliases for
CLIs without a listing API (source ``"static"``, ``verified: false``).
- ``cli-config`` → the codex curated static list (source ``"static"``,
``verified: false`` — the credential lives in the CLI's own config
file and is resolved by the CLI at launch).
@@ -40,6 +39,7 @@ import threading
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any
import click
import httpx
from cachetools import TTLCache
@@ -191,8 +191,8 @@ class ModelListing:
"""A worker's enumerated model list plus its provenance.
:param source: Where the list came from — ``"gateway"``,
``"openai-compatible"``, ``"anthropic-api"``, ``"static"``, or
``"none"``.
``"openai-compatible"``, ``"anthropic-api"``, ``"cli"``,
``"static"``, or ``"none"``.
:param verified: ``True`` when the list was fetched live from the
provider; ``False`` for static/curated or empty listings.
:param models: The enumerated models, e.g.
@@ -921,6 +921,8 @@ def _redacted_failure_reason(exc: Exception) -> str:
return "provider auth command timed out"
if isinstance(exc, subprocess.SubprocessError):
return "provider auth command failed"
if isinstance(exc, click.ClickException):
return exc.message
if isinstance(exc, httpx.HTTPStatusError):
return f"listing endpoint returned HTTP {exc.response.status_code}"
if isinstance(exc, httpx.HTTPError):
@@ -961,7 +963,7 @@ def _listing_for_provider(
"this worker cannot run here"
),
)
if provider.kind == SUBSCRIPTION_KIND:
if provider.kind == SUBSCRIPTION_KIND and provider.cli != "cursor-agent":
return _static_subscription_listing(provider)
if provider.kind == CLI_CONFIG_KIND:
return _static_cli_config_listing(provider)
@@ -972,13 +974,21 @@ def _listing_for_provider(
if cached is not None:
return cached
try:
if provider.kind == DATABRICKS_KIND:
if provider.kind == SUBSCRIPTION_KIND:
listing = _fetch_cursor_cli_listing(provider)
elif provider.kind == DATABRICKS_KIND:
listing = _fetch_databricks_listing(provider, transport=transport)
elif provider.kind == KEY_KIND and provider.family == ANTHROPIC_FAMILY:
listing = _fetch_anthropic_listing(provider, transport=transport)
else:
listing = _fetch_openai_compatible_listing(provider, transport=transport)
except (httpx.HTTPError, OSError, ValueError, subprocess.SubprocessError) as exc:
except (
click.ClickException,
httpx.HTTPError,
OSError,
ValueError,
subprocess.SubprocessError,
) as exc:
_logger.debug(
"model enumeration failed for %s", provider.detail or provider.kind, exc_info=True
)
@@ -996,6 +1006,22 @@ def _listing_for_provider(
return listing
def _fetch_cursor_cli_listing(provider: ResolvedModelProvider) -> ModelListing:
"""Build a live listing from the installed Cursor CLI."""
from omnigent.cursor_native import list_cursor_cli_model_options
options = list_cursor_cli_model_options()
return ModelListing(
source="cli",
verified=True,
models=tuple(
ModelEntry(id=str(option["id"]), family=model_family_token(str(option["id"])))
for option in options
),
note=f"live models advertised by the {provider.cli or 'cursor-agent'} CLI",
)
def _static_subscription_listing(provider: ResolvedModelProvider) -> ModelListing:
"""Build the curated static listing for a subscription CLI login.
@@ -1018,16 +1044,9 @@ def _static_subscription_listing(provider: ResolvedModelProvider) -> ModelListin
def _subscription_static_ids(cli: str) -> tuple[str, ...]:
"""Return the curated model ids for a subscription CLI.
:param cli: The CLI short-name, e.g. ``"claude"`` or ``"cursor-agent"``.
:param cli: The CLI short-name, e.g. ``"claude"`` or ``"codex"``.
:returns: Curated model ids; empty for an unknown CLI.
"""
if cli == "cursor-agent":
# Reuse the web picker's curated base-model catalog (derived from
# ``cursor-agent models``); imported lazily to keep this module off
# the TUI launcher's import path.
from omnigent.cursor_native import cursor_base_model_options
return tuple(str(option["id"]) for option in cursor_base_model_options())
return _SUBSCRIPTION_STATIC_MODELS.get(cli, ())
+38 -1
View File
@@ -1797,6 +1797,7 @@ def create_runner_app(
_session_init_envelopes: dict[str, tuple[float, RunnerSessionInitEnvelope]] = {}
_session_skills_cache: dict[str, tuple[float, list[SkillSpec]]] = {}
_session_workspace_cache: dict[str, str | None] = {} # session_id → workspace path
_session_cursor_model_names: dict[str, dict[str, str]] = {}
_session_claude_launch_configs: dict[str, ClaudeNativeUcodeConfig | None] = {}
_session_claude_launch_config_tasks: dict[
str, asyncio.Task[ClaudeNativeUcodeConfig | None]
@@ -3099,6 +3100,7 @@ def create_runner_app(
_session_spec_cache.pop(session_id, None)
_session_skills_cache.pop(session_id, None)
_session_cursor_model_names.pop(session_id, None)
_drop_session_claude_launch_config(session_id)
_session_start_cache.pop(session_id, None)
_session_workspace_cache.pop(session_id, None)
@@ -3970,11 +3972,14 @@ def create_runner_app(
if model is None or not model.strip():
return Response(status_code=204)
bridge_dir = bridge_dir_for_session_id(conv_id)
selected_model = model.strip()
expected_display_name = _session_cursor_model_names.get(conv_id, {}).get(selected_model)
try:
await asyncio.to_thread(
inject_model_command,
bridge_dir,
model=model.strip(),
model=selected_model,
expected_display_name=expected_display_name,
timeout_s=1.0,
)
except (RuntimeError, ValueError) as exc:
@@ -4905,6 +4910,7 @@ def create_runner_app(
)
_session_spec_cache.pop(conv, None)
_session_skills_cache.pop(conv, None)
_session_cursor_model_names.pop(conv, None)
_drop_session_claude_launch_config(conv)
_session_tool_schemas.pop(conv, None)
_session_snapshot_cache.pop(conv, None)
@@ -7513,6 +7519,36 @@ def create_runner_app(
)
return JSONResponse(status_code=200, content={"models": models})
@app.get("/v1/sessions/{session_id}/cursor-model-options")
async def get_session_cursor_model_options(session_id: str) -> JSONResponse:
if _session_harness_name(session_id) != "cursor-native":
return JSONResponse(status_code=200, content={"models": []})
from omnigent.cursor_native import list_cursor_cli_model_options
try:
models = await asyncio.to_thread(list_cursor_cli_model_options)
except Exception as exc: # noqa: BLE001 - picker failures are retryable.
_logger.warning(
"Cursor-native model discovery failed for session=%s",
session_id,
exc_info=True,
)
return JSONResponse(
status_code=503,
content={
"error": "cursor_native_model_options_failed",
"detail": _client_safe_error_detail(
exc, context="cursor-native model options"
),
},
)
_session_cursor_model_names[session_id] = {
str(option["id"]): str(option["displayName"])
for option in models
if option.get("id") and option.get("displayName")
}
return JSONResponse(status_code=200, content={"models": models})
@app.get("/v1/sessions/{session_id}/claude-model-options")
async def get_session_claude_model_options(session_id: str) -> JSONResponse:
if _session_harness_name(session_id) != "claude-native":
@@ -7758,6 +7794,7 @@ def create_runner_app(
def _clear_session_agent_caches(session_id: str, agent_id: str | None = None) -> None:
_session_spec_cache.pop(session_id, None)
_session_skills_cache.pop(session_id, None)
_session_cursor_model_names.pop(session_id, None)
_drop_session_claude_launch_config(session_id)
_session_tool_schemas.pop(session_id, None)
_session_mcp_spec_hash.pop(session_id, None)
+1 -1
View File
@@ -631,10 +631,10 @@ _UPLOAD_READ_CHUNK_BYTES: int = 1024 * 1024
# Live runner-owned model catalogs, keyed by wrapper label to route segment.
# Static catalogs bypass this cache so ``refresh_state`` cannot blank them.
_MODEL_OPTIONS_ENDPOINT_BY_WRAPPER: dict[str, str] = {
_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE: "claude-model-options",
_CODEX_NATIVE_WRAPPER_LABEL_VALUE: "codex-model-options",
_CURSOR_NATIVE_WRAPPER_LABEL_VALUE: "cursor-model-options",
_KIRO_NATIVE_WRAPPER_LABEL_VALUE: "kiro-model-options",
_OPENCODE_NATIVE_WRAPPER_LABEL_VALUE: "codex-model-options",
# pi-native is deliberately NOT here: its catalog is PUSHED by the resident
@@ -6323,18 +6323,13 @@ async def _fetch_model_options(
Three shapes:
* **cursor-native** a curated *static* base catalog
(:func:`omnigent.cursor_native.cursor_base_model_options`), returned
directly on every snapshot. It deliberately bypasses the runner-backed
cache below: the catalog never changes per session, and routing it
through that cache would let a ``refresh_state`` snapshot (which pops the
cache) blank the picker on an effort/model change.
* **codex-native / kiro-native** a *live* catalog only the bound runner
can read (its app-server ``model/list``). Like skills, this stays off the
snapshot hot path: the first snapshot kicks a background fetch and returns
``[]``; subsequent snapshots serve the cache. The cache outlives the
runner: with no runner bound (asleep session) it keeps serving, and a
stale-marked entry serves while a live re-fetch replaces it.
* **codex-native / cursor-native / kiro-native** a *live* catalog only
the bound runner can read from the installed CLI. Like skills, this stays
off the snapshot hot path: the first snapshot kicks a background fetch
and returns ``[]``; subsequent snapshots serve the cache. The cache
outlives the runner: with no runner bound (asleep session) it keeps
serving, and a stale-marked entry serves while a live re-fetch replaces
it.
* **claude-native** the provider-neutral aliases from the exact launch
config, refreshed from Databricks before each new terminal starts.
With no runner bound and a cold cache (server restart while the
@@ -6350,10 +6345,6 @@ async def _fetch_model_options(
the runner-owned options are not yet available.
"""
wrapper = conv.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
if wrapper == _CURSOR_NATIVE_WRAPPER_LABEL_VALUE:
from omnigent.cursor_native import cursor_base_model_options
return cursor_base_model_options()
if wrapper == _PI_NATIVE_WRAPPER_LABEL_VALUE:
# pi-native's catalog is PUSHED by its extension (its live
# ``ctx.modelRegistry``), not fetched: that reflects the models pi
@@ -6501,14 +6492,16 @@ async def _get_session_snapshot(
runner_client = get_runner_client()
if refresh_state:
# Re-discover runner-backed overlays. Drop the model catalog only
# when a live runner can serve the re-fetch immediately; with no
# runner bound the cached catalog is all there is — keep serving it
# (stale) so a reload of an asleep session doesn't blank the picker.
wrapper = conv.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
# Cursor effort/model changes refresh snapshots; keep its previous
# options visible until the asynchronous CLI re-fetch replaces them.
# Other live catalogs retain their existing drop-on-refresh contract.
_invalidate_runner_backed_snapshot_state(
session_id,
cancel_inflight=False,
drop_model_options=runner_client is not None,
drop_model_options=(
runner_client is not None and wrapper != _CURSOR_NATIVE_WRAPPER_LABEL_VALUE
),
)
status = _session_status_from_cache(session_id)
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env python3
"""Generate ``_CURSOR_BASE_MODELS`` for ``omnigent/cursor_native.py``.
The cursor-native web picker needs *base* model ids — the namespace that (a)
launches via ``cursor-agent --model <id>``, (b) injects via ``/model <id>`` in
the TUI, and (c) is what ``meta.lastUsedModel`` reports back to the web mirror.
``cursor-agent models`` (== ``--list-models``) instead prints ~80 *compound*
ids that flatten ``model x effort`` (``gpt-5.2-high``) and spell the claude
4.5/4.6 family with reversed word order + a dotted version
(``claude-4.6-opus-high`` vs the canonical ``claude-opus-4-6``).
This script derives the base-id catalog from that output: it strips the trailing
effort/thinking group to recover the base id, applies ``_BASE_ID_OVERRIDES`` for
the irregular claude spellings, drops ``_DENYLIST_PREFIXES`` (prefix-collision /
unoffered tiers), derives a clean display name, and carries the ``(default)``
tag. ``(current)`` is ignored — it is per-session state, not a catalog property.
Usage::
cursor-agent models | python scripts/gen_cursor_models.py
python scripts/gen_cursor_models.py # shells out to ``cursor-agent models``
Re-run when cursor ships new models and paste the printed literal into
``omnigent/cursor_native.py`` (between the ``# >>> generated`` markers). Review
the diff: a brand-new model with a *new* irregular spelling needs a one-line
``_BASE_ID_OVERRIDES`` entry, and the script warns when it sees an unmapped
claude reordering so the gap never lands silently.
"""
from __future__ import annotations
import re
import subprocess
import sys
# Trailing effort / thinking tokens cursor appends to a base id. Order matters:
# multi-word ``extra-high`` must be tried before ``high``. The group repeats so
# ``claude-opus-4-8-thinking-high`` and ``claude-4.6-opus-high-thinking`` (the
# two orderings cursor uses) both strip fully.
_EFFORT_TOKENS = ("extra-high", "thinking", "xhigh", "medium", "high", "low", "none", "max")
_EFFORT_SUFFIX_RE = re.compile(r"(?:-(?:" + "|".join(_EFFORT_TOKENS) + r"))+$")
# Irregular cursor spellings -> canonical base id (verified to inject via
# ``/model`` and to match what ``meta.lastUsedModel`` reports). Only the claude
# 4.5/4.6 family reverses order + dots the version; 4.7/4.8 already use the
# canonical ``claude-opus-4-N`` form and need no override.
_BASE_ID_OVERRIDES: dict[str, str] = {
"claude-4.6-opus": "claude-opus-4-6",
"claude-4.6-sonnet": "claude-sonnet-4-6",
"claude-4.5-opus": "claude-opus-4-5",
"claude-4.5-sonnet": "claude-sonnet-4-5",
}
# Base-id prefixes to exclude from the picker, each with its reason. Matched
# against the *derived* base id, so a prefix kills a whole family.
_DENYLIST_PREFIXES: dict[str, str] = {
"gpt-5.1": "prefix-collision: /model gpt-5.1 mis-ranks to 'Codex 5.1 Max'",
"gpt-5-mini": "low-value tier, not offered in the picker",
"gpt-5.4-mini": "low-value tier, not offered in the picker",
"gpt-5.4-nano": "low-value tier, not offered in the picker",
"gemini-3-flash": "flash tier not offered in the picker",
"gemini-3.5-flash": "flash tier not offered in the picker",
"claude-4-sonnet": "Sonnet 4: base-id injection not yet verified",
}
# Trailing display-name words that describe effort/context rather than the
# model, stripped to recover a clean label ("Opus 4.8 1M Extra High" -> "Opus 4.8").
_DISPLAY_STRIP_WORDS = {"1m", "none", "low", "medium", "high", "max", "thinking", "extra"}
# Family display order (claude opus, claude sonnet, gpt, codex, gemini), with
# ``auto`` and the account default pinned to the top.
_FAMILY_RANK = {
"auto": 0,
"composer": 1,
"opus": 2,
"sonnet": 3,
"gpt": 4,
"codex": 5,
"gemini": 6,
}
_LINE_RE = re.compile(r"^(?P<id>\S+)\s+-\s+(?P<name>.+?)(?:\s+\((?P<tags>[^)]*)\))?$")
def _base_id(compound_id: str) -> str:
"""Recover the canonical base id from a compound ``--list-models`` id."""
stripped = _EFFORT_SUFFIX_RE.sub("", compound_id)
return _BASE_ID_OVERRIDES.get(stripped, stripped)
def _clean_display(name: str) -> str:
"""Strip trailing effort/context words from a model's display name."""
words = name.split()
while words and words[-1].lower() in _DISPLAY_STRIP_WORDS:
words.pop()
return " ".join(words)
def _family(base_id: str) -> str:
if base_id == "auto":
return "auto"
if base_id.startswith("composer"):
return "composer"
if "opus" in base_id:
return "opus"
if "sonnet" in base_id:
return "sonnet"
if "codex" in base_id:
return "codex"
if base_id.startswith("gpt"):
return "gpt"
if base_id.startswith("gemini"):
return "gemini"
return "other"
def _version(base_id: str) -> tuple[float, ...]:
"""Numeric version for descending sort within a family (4.8 before 4.7)."""
nums = [int(n) for n in re.findall(r"\d+", base_id)]
return tuple(nums) if nums else (0,)
def _denied(base_id: str) -> str | None:
for prefix, reason in _DENYLIST_PREFIXES.items():
if base_id == prefix or base_id.startswith(prefix + "-"):
return reason
return None
def main() -> int:
if sys.stdin.isatty():
raw = subprocess.run(
["cursor-agent", "models"], capture_output=True, text=True, check=True
).stdout
else:
raw = sys.stdin.read()
# Derive base id -> (display, is_default), first compound variant wins for
# the display name; is_default is OR-ed across the family's variants.
derived: dict[str, dict[str, object]] = {}
for line in raw.splitlines():
m = _LINE_RE.match(line.strip())
if not m or " " in m.group("id"):
continue
compound = m.group("id")
tags = {t.strip() for t in (m.group("tags") or "").split(",") if t.strip()}
base = _base_id(compound)
# Warn on an unmapped claude reordering so a new one never lands silently.
if base.startswith("claude-4."):
print(
f"WARNING: unmapped irregular claude id {compound!r} -> {base!r}; "
f"add a _BASE_ID_OVERRIDES entry",
file=sys.stderr,
)
entry = derived.setdefault(
base, {"display": _clean_display(m.group("name")), "default": False}
)
if "default" in tags:
entry["default"] = True
rows = []
for base, entry in derived.items():
reason = _denied(base)
if reason:
print(f" skip {base:<22} ({reason})", file=sys.stderr)
continue
rows.append((base, str(entry["display"]), bool(entry["default"])))
rows.sort(
key=lambda r: (_FAMILY_RANK.get(_family(r[0]), 99), tuple(-n for n in _version(r[0])))
)
print("_CURSOR_BASE_MODELS: list[dict[str, Any]] = [")
for base, display, is_default in rows:
suffix = ', "isDefault": True' if is_default else ""
print(f' {{"id": "{base}", "displayName": "{display}"{suffix}}},')
print("]")
print(f"\n# {len(rows)} models", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -13,6 +13,8 @@ import pytest
from omnigent import (
claude_native_bridge,
codex_native_bridge,
cursor_native,
cursor_native_bridge,
kiro_native,
kiro_native_bridge,
)
@@ -325,6 +327,104 @@ async def test_kiro_native_model_options_failure_is_retryable(
assert response.json()["error"] == "kiro_native_model_options_failed"
@pytest.mark.asyncio
async def test_cursor_native_model_options_use_cli_catalog(
monkeypatch: pytest.MonkeyPatch,
) -> None:
conv_id = "c7e721bf0e124d2fb5bc1bc36772864e"
expected = [
{
"id": "provider-latest",
"displayName": "Provider Latest",
"isDefault": True,
"isCurrent": False,
}
]
monkeypatch.setattr(cursor_native, "list_cursor_cli_model_options", lambda: expected)
injected: list[tuple[str, str | None]] = []
def _inject_model(
_bridge_dir: Path,
*,
model: str,
expected_display_name: str | None,
timeout_s: float,
) -> None:
del timeout_s
injected.append((model, expected_display_name))
monkeypatch.setattr(cursor_native_bridge, "inject_model_command", _inject_model)
spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return spec
app = create_runner_app(
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={"session_id": conv_id, "agent_id": "ag_1"},
)
assert create_resp.status_code == 201, create_resp.text
response = await client.get(f"/v1/sessions/{conv_id}/cursor-model-options")
event_response = await client.post(
f"/v1/sessions/{conv_id}/events",
json={"type": "model_change", "model": "provider-latest"},
)
assert response.status_code == 200
assert response.json() == {"models": expected}
assert event_response.status_code == 204
assert injected == [("provider-latest", "Provider Latest")]
@pytest.mark.asyncio
async def test_cursor_native_model_options_failure_is_retryable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Discovery failures return 503 so the server leaves its cache cold."""
conv_id = "d29b45fd569245b2bc0dd79694e73886"
def _fail_discovery() -> list[dict[str, object]]:
raise RuntimeError("catalog unavailable")
monkeypatch.setattr(cursor_native, "list_cursor_cli_model_options", _fail_discovery)
spec = AgentSpec(
spec_version=1,
name="t",
executor=ExecutorSpec(type="omnigent", config={"harness": "cursor-native"}),
)
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
del agent_id, session_id
return spec
app = create_runner_app(
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
spec_resolver=_resolver,
server_client=NullServerClient(), # type: ignore[arg-type]
)
async with _runner_client(app) as client:
create_resp = await client.post(
"/v1/sessions",
json={"session_id": conv_id, "agent_id": "ag_1"},
)
assert create_resp.status_code == 201, create_resp.text
response = await client.get(f"/v1/sessions/{conv_id}/cursor-model-options")
assert response.status_code == 503, response.text
assert response.json()["error"] == "cursor_native_model_options_failed"
@pytest.mark.asyncio
async def test_opencode_native_model_options_uses_cli_catalog(
monkeypatch: pytest.MonkeyPatch,
@@ -2223,11 +2223,17 @@ async def test_events_model_change_on_cursor_native_session_types_slash_command(
"""
from omnigent.spec.types import ExecutorSpec
captured: list[tuple[Any, str, float]] = []
captured: list[tuple[Any, str, str | None, float]] = []
def _fake_inject(bridge_dir: Any, *, model: str, timeout_s: float) -> None:
def _fake_inject(
bridge_dir: Any,
*,
model: str,
expected_display_name: str | None,
timeout_s: float,
) -> None:
"""Record the call and return without touching tmux."""
captured.append((bridge_dir, model, timeout_s))
captured.append((bridge_dir, model, expected_display_name, timeout_s))
monkeypatch.setattr(cursor_native_bridge, "inject_model_command", _fake_inject)
@@ -2269,8 +2275,9 @@ async def test_events_model_change_on_cursor_native_session_types_slash_command(
f"got {resp.status_code}: {resp.text}"
)
assert len(captured) == 1, f"Expected one inject_model_command call, got {len(captured)}."
_bridge_dir, model, timeout_s = captured[0]
_bridge_dir, model, expected_display_name, timeout_s = captured[0]
assert model == "gpt-5.2", f"Expected the model id passed through, got {model!r}."
assert expected_display_name is None
assert timeout_s == 1.0
@@ -2348,9 +2355,15 @@ async def test_events_model_change_on_cursor_native_session_returns_503_when_not
"""
from omnigent.spec.types import ExecutorSpec
def _fake_inject(bridge_dir: Any, *, model: str, timeout_s: float) -> None:
def _fake_inject(
bridge_dir: Any,
*,
model: str,
expected_display_name: str | None,
timeout_s: float,
) -> None:
"""Simulate the bridge-not-ready path."""
del bridge_dir, model, timeout_s
del bridge_dir, model, expected_display_name, timeout_s
raise RuntimeError("tmux target is not advertised")
monkeypatch.setattr(cursor_native_bridge, "inject_model_command", _fake_inject)
+44 -37
View File
@@ -1045,18 +1045,10 @@ async def test_session_snapshot_serves_pi_model_options_from_extension_push(
@pytest.mark.asyncio
async def test_session_snapshot_serves_static_cursor_model_options(
async def test_session_snapshot_fetches_live_cursor_model_options(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Cursor-native model options are a curated *static* catalog, served directly.
Unlike codex (live runner ``model/list``), cursor's catalog never changes
per session, so the snapshot returns it on the FIRST read with no runner
round-trip and no background fetch. Serving it directly (not through the
runner-backed cache) is what keeps the picker from blanking on a
``refresh_state`` snapshot — the regression behind the effort-change bug.
"""
"""Cursor options are fetched from the runner and cached for later snapshots."""
from omnigent.server.routes import sessions as _mod
_mod._session_status_cache.clear()
@@ -1081,6 +1073,18 @@ async def test_session_snapshot_serves_static_cursor_model_options(
self.get_calls.append(url)
if url.endswith("/skills"):
return _FakeResponse({"skills": []})
if url.endswith("/cursor-model-options"):
return _FakeResponse(
{
"models": [
{
"id": "provider-latest",
"displayName": "Provider Latest",
"isDefault": True,
}
]
}
)
return _FakeResponse({"status": "idle"})
fake_client = _FakeRunnerClient()
@@ -1102,34 +1106,40 @@ async def test_session_snapshot_serves_static_cursor_model_options(
conversations={"4747fb03a3b45bb1f96bf130f4d704e5": conv},
)
# First snapshot already carries the full catalog — no kick-and-empty.
first = await _get_session_snapshot(
conv_store, # type: ignore[arg-type]
"4747fb03a3b45bb1f96bf130f4d704e5",
)
assert first.model_options == []
await _drain_model_options("4747fb03a3b45bb1f96bf130f4d704e5")
snapshot = await _get_session_snapshot(
conv_store, # type: ignore[arg-type]
"4747fb03a3b45bb1f96bf130f4d704e5",
)
# No runner round-trip for cursor model options (served statically).
assert not any("model-options" in url for url in fake_client.get_calls)
ids = [m.id for m in snapshot.model_options]
assert "claude-opus-4-6" in ids and "gpt-5.2" in ids and "composer-2.5" in ids
# base-id namespace only — no flattened effort variants leak through.
assert not any("-high" in i or "-xhigh" in i for i in ids)
# The cache must stay untouched — that's what makes it refresh_state-proof.
assert "4747fb03a3b45bb1f96bf130f4d704e5" not in _mod._model_options_cache
assert [m.id for m in snapshot.model_options] == ["provider-latest"]
assert snapshot.model_options[0].displayName == "Provider Latest"
assert (
"/v1/sessions/4747fb03a3b45bb1f96bf130f4d704e5/cursor-model-options"
in fake_client.get_calls
)
assert "4747fb03a3b45bb1f96bf130f4d704e5" in _mod._model_options_cache
@pytest.mark.asyncio
async def test_session_snapshot_refresh_state_reloads_model_options(
@pytest.mark.parametrize("wrapper_name", ["cursor", "codex"])
async def test_snapshot_refresh_scopes_cached_options_to_cursor(
monkeypatch: pytest.MonkeyPatch,
wrapper_name: str,
) -> None:
"""
``refresh_state=True`` pierces stale runner-backed Codex catalogs.
``refresh_state=True`` retains only Cursor's previous picker options.
Browser reloads pass this flag so an AP-process cache warmed by an older
bug or older Codex response does not keep driving the model picker after
refresh. The first refreshed snapshot must not serve the stale cached row;
once the background runner read lands, a later snapshot serves the live
catalog.
Browser reloads and effort changes can request a refresh while the runner
catalog fetch is still in flight. The previous catalog remains available
for Cursor until the live response replaces it; Codex retains its existing
drop-on-refresh behavior.
"""
from omnigent.server.routes import sessions as _mod
@@ -1165,18 +1175,13 @@ async def test_session_snapshot_refresh_state_reloads_model_options(
self.get_calls.append(url)
if url.endswith("/skills"):
return _FakeResponse({"skills": []})
if url.endswith("/codex-model-options"):
if url.endswith(f"/{wrapper_name}-model-options"):
return _FakeResponse(
{
"models": [
{
"id": "fresh-model",
"model": "fresh-provider-model",
"displayName": "Fresh Model",
"defaultReasoningEffort": "high",
"supportedReasoningEfforts": [
{"reasoningEffort": "high", "description": "High"}
],
"isDefault": True,
}
]
@@ -1195,7 +1200,10 @@ async def test_session_snapshot_refresh_state_reloads_model_options(
root_conversation_id="3626053dfa9668a8604cc06e0b590ae0",
agent_id="087b7cb7ac30abf4debfaa578d052ec6",
labels={
_mod._CLAUDE_NATIVE_WRAPPER_LABEL_KEY: _mod._CODEX_NATIVE_WRAPPER_LABEL_VALUE,
_mod._CLAUDE_NATIVE_WRAPPER_LABEL_KEY: getattr(
_mod,
f"_{wrapper_name.upper()}_NATIVE_WRAPPER_LABEL_VALUE",
),
},
)
conv_store = _ConversationStore(
@@ -1208,9 +1216,8 @@ async def test_session_snapshot_refresh_state_reloads_model_options(
"3626053dfa9668a8604cc06e0b590ae0",
refresh_state=True,
)
# Refresh must not echo the stale cached row. If this is "stale-model",
# browser reloads would not recover after the server-side cache shape is fixed.
assert [m.id for m in refreshed.model_options] == []
expected_during_refresh = ["stale-model"] if wrapper_name == "cursor" else []
assert [m.id for m in refreshed.model_options] == expected_during_refresh
await _drain_model_options("3626053dfa9668a8604cc06e0b590ae0")
snapshot = await _get_session_snapshot(
conv_store, # type: ignore[arg-type]
@@ -1218,7 +1225,7 @@ async def test_session_snapshot_refresh_state_reloads_model_options(
)
assert (
"/v1/sessions/3626053dfa9668a8604cc06e0b590ae0/codex-model-options"
f"/v1/sessions/3626053dfa9668a8604cc06e0b590ae0/{wrapper_name}-model-options"
in fake_client.get_calls
)
assert [m.id for m in snapshot.model_options] == ["fresh-model"]
+99 -28
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import httpx
@@ -160,41 +161,111 @@ async def test_cursor_cold_resume_pins_model(
]
def test_cursor_base_model_options_shape() -> None:
"""The curated base catalog yields id/displayName/isDefault/isCurrent dicts."""
_CURSOR_MODELS_OUTPUT = """Available models
models = cursor_native.cursor_base_model_options()
assert models, "catalog must be non-empty"
assert all(set(m) == {"id", "displayName", "isDefault", "isCurrent"} for m in models)
# Exactly one default (composer-2.5 is the cursor account default), never current.
assert [m["id"] for m in models if m["isDefault"]] == ["composer-2.5"]
assert all(m["isCurrent"] is False for m in models)
auto - Auto (default)
gpt-5.3-codex-low - Codex 5.3 Low
gpt-5.3-codex-high-fast - Codex 5.3 High Fast
gpt-5.1-high - GPT-5.1 High
claude-4.6-opus-high - Opus 4.6 1M
claude-4.6-opus-high-thinking - Opus 4.6 1M Thinking
claude-4-sonnet-thinking - Sonnet 4 Thinking
composer-2.5 - Composer 2.5 (current)
"""
def test_cursor_base_model_options_uses_base_id_namespace() -> None:
"""Ids are base ids (round-trip across launch / inject / mirror), not compound.
def test_parse_cursor_cli_model_options_normalizes_base_ids() -> None:
"""Live compound variants collapse to injectable base ids in CLI order."""
models = cursor_native.parse_cursor_cli_model_options(_CURSOR_MODELS_OUTPUT)
Pins the namespace contract: the compound ``--list-models`` ids
(``gpt-5.2-high``) and the ``--list-models`` claude spelling
(``claude-4.6-opus``) do NOT inject via ``/model``; the base ids
(``claude-opus-4-6``) do and are what ``meta.lastUsedModel`` reports.
"""
ids = {m["id"] for m in cursor_native.cursor_base_model_options()}
assert {"claude-opus-4-6", "gpt-5.2", "composer-2.5"} <= ids
# No flattened effort variants and no --list-models claude reordering.
assert not any("-high" in i or "-low" in i or "-xhigh" in i for i in ids)
assert "claude-4.6-opus" not in ids
assert models == [
{"id": "auto", "displayName": "Auto", "isDefault": True, "isCurrent": False},
{
"id": "gpt-5.3-codex",
"displayName": "Codex 5.3",
"isDefault": False,
"isCurrent": False,
},
{
"id": "gpt-5.1",
"displayName": "GPT-5.1",
"isDefault": False,
"isCurrent": False,
},
{
"id": "claude-opus-4-6",
"displayName": "Opus 4.6",
"isDefault": False,
"isCurrent": False,
},
{
"id": "composer-2.5",
"displayName": "Composer 2.5",
"isDefault": False,
"isCurrent": True,
},
]
def test_cursor_base_model_options_returns_fresh_copies() -> None:
"""Callers may mutate the returned dicts without corrupting the catalog."""
def test_parse_cursor_cli_model_options_keeps_one_default_and_current() -> None:
"""Conflicting CLI tags resolve deterministically in catalog order."""
models = cursor_native.parse_cursor_cli_model_options(
"""Available models
first-high - First High (default, current)
second-low - Second Low (default, current)
"""
)
first = cursor_native.cursor_base_model_options()
first[0]["displayName"] = "MUTATED"
second = cursor_native.cursor_base_model_options()
assert second[0]["displayName"] != "MUTATED"
assert [model["id"] for model in models if model["isDefault"]] == ["first"]
assert [model["id"] for model in models if model["isCurrent"]] == ["first"]
def test_parse_cursor_cli_model_options_logs_unmapped_claude_ids(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Reversed Claude ids that cannot round-trip never reach the picker."""
models = cursor_native.parse_cursor_cli_model_options(_CURSOR_MODELS_OUTPUT)
assert all(model["id"] != "claude-4-sonnet" for model in models)
assert "Skipping non-injectable Cursor model id 'claude-4-sonnet'" in caplog.text
def test_parse_cursor_cli_model_options_rejects_empty_catalog() -> None:
"""Malformed CLI output is retryable rather than cached as an empty picker."""
with pytest.raises(ValueError, match="did not contain any valid models"):
cursor_native.parse_cursor_cli_model_options("Available models\n")
def test_list_cursor_cli_model_options_runs_configured_binary(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Discovery invokes the resolved CLI and parses its stdout."""
calls: list[dict[str, Any]] = []
monkeypatch.setattr(
cursor_native,
"resolve_cursor_executable",
lambda **_: "/opt/cursor-agent",
)
def run(command: list[str], **kwargs: Any) -> SimpleNamespace:
calls.append({"command": command, **kwargs})
return SimpleNamespace(stdout=_CURSOR_MODELS_OUTPUT)
monkeypatch.setattr(cursor_native.subprocess, "run", run)
models = cursor_native.list_cursor_cli_model_options(env={"HOME": "/tmp/home"}, timeout_s=3.0)
assert models[0]["id"] == "auto"
assert calls == [
{
"command": ["/opt/cursor-agent", "models"],
"check": True,
"capture_output": True,
"text": True,
"timeout": 3.0,
"env": {"HOME": "/tmp/home"},
}
]
async def _async_noop(*_: object, **__: object) -> None:
+79 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from pathlib import Path
import click
import pytest
from omnigent import cursor_native_bridge
@@ -194,23 +195,52 @@ class TestInjectModelGate:
) -> None:
"""A landed filter ("Models matching") commits the selection with Enter."""
bridge_dir = _prepare_bridge(tmp_path)
monkeypatch.setattr(
cursor_native_bridge,
"_live_cursor_model_options",
lambda: pytest.fail("cached display name must avoid a second CLI listing"),
)
captured = _install_fake_tmux(
monkeypatch, pane_captures=[f'{_IDLE}\nModels matching "gpt-5.2"\n → GPT-5.2 High']
)
monkeypatch.setattr(cursor_native_bridge.time, "sleep", lambda *_a, **_k: None)
cursor_native_bridge.inject_model_command(bridge_dir, model="gpt-5.2")
cursor_native_bridge.inject_model_command(
bridge_dir,
model="gpt-5.2",
expected_display_name="GPT-5.2",
)
tails = _send_keys_calls(captured)
assert ["-t", _TARGET, "-l", "/model gpt-5.2"] in tails # the filter command
assert ["-t", _TARGET, "Enter"] in tails # selection committed
assert ["-t", _TARGET, "Escape"] not in tails # no dismiss on a real match
def test_missing_cli_catalog_raises_clean_runtime_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A cold switch without cursor-agent degrades through the runner's handled error path."""
def _missing_cli() -> list[dict[str, object]]:
raise click.ClickException("cursor-agent missing")
monkeypatch.setattr(cursor_native_bridge, "_live_cursor_model_options", _missing_cli)
with pytest.raises(RuntimeError, match="could not verify the live model catalog") as error:
cursor_native_bridge.inject_model_command(tmp_path, model="gpt-5.2")
assert isinstance(error.value.__cause__, click.ClickException)
def test_raises_without_enter_on_no_match(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
""" "No matches" fails loudly, dismisses the picker, and never presses Enter."""
bridge_dir = _prepare_bridge(tmp_path)
monkeypatch.setattr(
cursor_native_bridge,
"_live_cursor_model_options",
lambda: [{"id": "bogus-model", "displayName": "Bogus Model"}],
)
captured = _install_fake_tmux(
monkeypatch, pane_captures=[f"{_IDLE}\n → /model bogus-model\n No matches"]
)
@@ -233,6 +263,11 @@ class TestInjectModelGate:
matching" header the gate must refuse to press Enter.
"""
bridge_dir = _prepare_bridge(tmp_path)
monkeypatch.setattr(
cursor_native_bridge,
"_live_cursor_model_options",
lambda: [{"id": "gpt-5.2", "displayName": "GPT-5.2"}],
)
captured = _install_fake_tmux(
monkeypatch, pane_captures=[f"{_IDLE}\n → /model gpt-5.2\n No matches"]
)
@@ -243,6 +278,49 @@ class TestInjectModelGate:
assert ["-t", _TARGET, "Enter"] not in _send_keys_calls(captured)
def test_rejects_fuzzy_match_for_a_different_model(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A fuzzy top result cannot silently select a different catalog row."""
bridge_dir = _prepare_bridge(tmp_path)
monkeypatch.setattr(
cursor_native_bridge,
"_live_cursor_model_options",
lambda: [{"id": "requested-model", "displayName": "Requested Model"}],
)
captured = _install_fake_tmux(
monkeypatch,
pane_captures=[
f'{_IDLE}\nModels matching "requested-model"\n → Different Model High'
],
)
monkeypatch.setattr(cursor_native_bridge.time, "sleep", lambda *_a, **_k: None)
with pytest.raises(RuntimeError, match="exact picker row"):
cursor_native_bridge.inject_model_command(bridge_dir, model="requested-model")
tails = _send_keys_calls(captured)
assert ["-t", _TARGET, "Enter"] not in tails
assert ["-t", _TARGET, "Escape"] in tails
@pytest.mark.parametrize(
("row", "display_name", "expected"),
[
("Provider Model", "Provider Model", True),
("Provider Model High", "Provider Model", True),
("Provider Modelish High", "Provider Model", False),
("Other Provider Model", "Provider Model", False),
],
)
def test_picker_row_matches_complete_display_label(
row: str,
display_name: str,
expected: bool,
) -> None:
"""Variant suffixes are allowed, but longer-name prefixes are not."""
assert cursor_native_bridge._picker_row_matches_display(row, display_name) is expected
class TestHooksConfig:
"""The ``hooks.json`` that registers cursor's per-turn usage ``stop`` hook."""
-111
View File
@@ -1,111 +0,0 @@
"""Tests for ``scripts/gen_cursor_models.py`` base-id derivation.
These pin the parsing rules that turn ``cursor-agent models`` compound ids into
the canonical base-id catalog: effort-suffix stripping, the irregular-claude
override map, the prefix denylist, display-name cleaning, and ``(default)`` vs
``(current)`` handling. They feed a fixed fixture through the script's stdin, so
they run in CI without the ``cursor-agent`` binary.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "gen_cursor_models.py"
# Representative slice of ``cursor-agent models`` output exercising every rule:
# bare ids, the (default)/(current) tags, simple + multi-token + two-ordering
# effort suffixes, the claude 4.6 reversal override, and three denylist reasons.
_FIXTURE = """Available models
auto - Auto
composer-2.5 - Composer 2.5 (default)
gpt-5.2 - GPT-5.2 (current)
gpt-5.2-high - GPT-5.2 High
gpt-5.5-extra-high - GPT-5.5 1M Extra High
claude-opus-4-8-thinking-high - Opus 4.8 1M Thinking
claude-opus-4-8-max - Opus 4.8 1M Max
claude-4.6-opus-high - Opus 4.6 1M
claude-4.6-opus-high-thinking - Opus 4.6 1M Thinking
claude-4.6-sonnet-medium - Sonnet 4.6 1M
gpt-5.1 - GPT-5.1
gpt-5.1-codex-max-low - Codex 5.1 Max Low
gpt-5.4-mini-none - GPT-5.4 Mini None
gemini-3-flash - Gemini 3 Flash
claude-4-sonnet - Sonnet 4
"""
def _run(fixture: str) -> tuple[dict[str, dict], str]:
"""Run the generator on *fixture* via stdin; return {id: row} and stderr."""
proc = subprocess.run(
[sys.executable, str(_SCRIPT)],
input=fixture,
capture_output=True,
text=True,
check=True,
)
rows: dict[str, dict] = {}
for m in re.finditer(
r'\{"id": "([^"]+)", "displayName": "([^"]+)"(, "isDefault": True)?\}', proc.stdout
):
rows[m.group(1)] = {"displayName": m.group(2), "isDefault": bool(m.group(3))}
return rows, proc.stderr
def test_derives_canonical_base_ids() -> None:
"""Effort suffixes strip; the claude 4.6 reversal maps to the canonical id."""
rows, _ = _run(_FIXTURE)
# Bare ids kept; compound ids collapse to their base.
assert "gpt-5.2" in rows
assert "gpt-5.5" in rows # from gpt-5.5-extra-high (multi-token effort)
assert "claude-opus-4-8" in rows # both -thinking-high and -max collapse here
# Irregular claude 4.6 spelling -> canonical injectable base id.
assert "claude-opus-4-6" in rows
assert "claude-sonnet-4-6" in rows
def test_never_emits_compound_or_reordered_ids() -> None:
"""The flattened/effort and reordered-claude spellings never leak through."""
rows, _ = _run(_FIXTURE)
for bad in ("gpt-5.2-high", "gpt-5.5-extra-high", "claude-4.6-opus", "claude-4.6-opus-high"):
assert bad not in rows
def test_denylisted_families_are_dropped() -> None:
"""Prefix-collision, low-value tiers, and unverified spellings are excluded."""
rows, stderr = _run(_FIXTURE)
assert "gpt-5.1" not in rows # prefix-collision
assert "gpt-5.1-codex" not in rows # from gpt-5.1-codex-max-low, dropped by gpt-5.1 prefix
assert "gpt-5.4-mini" not in rows
assert "gemini-3-flash" not in rows
assert "claude-4-sonnet" not in rows
# Sonnet 4.6 must NOT be collateral damage of a too-greedy sonnet prefix.
assert "claude-sonnet-4-6" in rows
assert "skip" in stderr # the denylist reasons are logged for review
def test_default_tag_carried_current_tag_ignored() -> None:
"""``(default)`` -> isDefault; ``(current)`` (per-session) is ignored."""
rows, _ = _run(_FIXTURE)
assert rows["composer-2.5"]["isDefault"] is True
assert rows["gpt-5.2"]["isDefault"] is False # tagged (current), not (default)
assert [mid for mid, r in rows.items() if r["isDefault"]] == ["composer-2.5"]
def test_display_names_strip_effort_and_context_words() -> None:
"""Trailing effort/context words are stripped to a clean label."""
rows, _ = _run(_FIXTURE)
assert rows["gpt-5.5"]["displayName"] == "GPT-5.5" # "GPT-5.5 1M Extra High" cleaned
assert rows["claude-opus-4-8"]["displayName"] == "Opus 4.8" # "Opus 4.8 1M Thinking" cleaned
assert rows["claude-sonnet-4-6"]["displayName"] == "Sonnet 4.6" # "Sonnet 4.6 1M" cleaned
assert rows["auto"]["displayName"] == "Auto"
def test_unmapped_irregular_claude_spelling_warns() -> None:
"""A new reversed-claude id with no override is flagged, not silently mangled."""
_, stderr = _run("Available models\n\nclaude-4.9-opus-high - Opus 4.9 1M\n")
assert "unmapped irregular claude id" in stderr
+44 -9
View File
@@ -866,23 +866,58 @@ def test_cli_config_listing_is_static_and_unverified(
assert "cannot run here" not in listing.note
def test_cursor_listing_is_static_with_curated_base_models(
def test_cursor_listing_uses_live_cli_base_models(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A cursor worker lists the curated cursor-agent base models.
"""A cursor worker lists base models discovered from cursor-agent.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp dir.
"""
from omnigent import cursor_native
_isolate_config(monkeypatch, tmp_path, "")
monkeypatch.setattr(
cursor_native,
"list_cursor_cli_model_options",
lambda: [
{
"id": "provider-latest",
"displayName": "Provider Latest",
"isDefault": True,
"isCurrent": False,
}
],
)
listing = list_models_for_worker(_worker_spec("cursor-native"), "cursor-native")
assert listing.source == "static"
assert listing.verified is False
ids = [m.id for m in listing.models]
# Spot-check the picker catalog rather than pinning the whole list —
# it is regenerated when cursor ships models.
assert "composer-2.5" in ids
assert "cannot run here" not in listing.note
assert listing.source == "cli"
assert listing.verified is True
assert [m.id for m in listing.models] == ["provider-latest"]
assert "live models advertised" in listing.note
def test_cursor_listing_failure_is_empty_and_retryable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A transient Cursor CLI failure does not cache an empty catalog."""
from omnigent import cursor_native
_isolate_config(monkeypatch, tmp_path, "")
calls = 0
def fail() -> list[dict[str, object]]:
nonlocal calls
calls += 1
raise OSError("cursor unavailable")
monkeypatch.setattr(cursor_native, "list_cursor_cli_model_options", fail)
first = list_models_for_worker(_worker_spec("cursor-native"), "cursor-native")
second = list_models_for_worker(_worker_spec("cursor-native"), "cursor-native")
assert first.source == second.source == "none"
assert first.models == second.models == ()
assert calls == 2
def test_none_listing_explains_dead_worker(