[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:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user