diff --git a/tests/test_console_available_models.py b/tests/test_console_available_models.py index caf331e9..b058c338 100644 --- a/tests/test_console_available_models.py +++ b/tests/test_console_available_models.py @@ -67,6 +67,7 @@ def _make_client( storage: SQLiteBackend, *, settings: dict[str, str] | None = None, + registry_default: str | None = None, ) -> TestClient: app = Starlette( routes=[Route("/v1/api/models", list_available_models)], @@ -74,6 +75,12 @@ def _make_client( ) app.state.auth_storage = storage app.state.config_store = _FakeConfigStore(dict(settings or {})) + if registry_default is not None: + # Mimic the live console's ``coord_registry`` shape — the handler + # only reads ``.default``, so a SimpleNamespace is enough. + from types import SimpleNamespace + + app.state.coord_registry = SimpleNamespace(default=registry_default) client = TestClient(app) client.headers.update({"X-Test-User": "admin", "X-Test-Perms": ""}) return client @@ -165,6 +172,34 @@ def test_coordinator_set_to_unknown_alias_falls_back_to_default( assert body["coordinator_default_alias"] == "primary" +def test_coordinator_falls_back_to_registry_default_when_config_store_empty( + storage: SQLiteBackend, +) -> None: + """Match ``console/session_factory.py:109-110``: when both + ``coordinator.model_alias`` and ``model.default_alias`` are unset, new + coordinator sessions run on ``registry.default`` (loaded from + config.toml ``[model].default``). The placeholder must report the + same alias rather than going blank — otherwise the home composer + advertises "Default model" while sessions actually launch on a + concrete alias.""" + _seed_model(storage, definition_id="m1", alias="primary") + body = _get_models(_make_client(storage, registry_default="primary")) + assert body["default_alias"] == "" + assert body["coordinator_default_alias"] == "primary" + assert body["judge_default_alias"] == "primary" + + +def test_coordinator_skips_registry_default_when_alias_disabled( + storage: SQLiteBackend, +) -> None: + """Registry default points at an alias that's been disabled in the DB + — the placeholder stays blank rather than advertising a model that + workstream creation would refuse to use.""" + _seed_model(storage, definition_id="m1", alias="legacy", enabled=False) + body = _get_models(_make_client(storage, registry_default="legacy")) + assert body["coordinator_default_alias"] == "" + + # --------------------------------------------------------------------------- # Judge resolution # --------------------------------------------------------------------------- diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 584b9ce8..33648fd5 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -1684,15 +1684,25 @@ async def list_available_models(request: Request) -> JSONResponse: default_alias = "" if channel_default_alias and channel_default_alias not in enabled_aliases: channel_default_alias = "" - # Coordinator falls back to model.default_alias when its setting is - # empty or points at a disabled/removed alias (matches - # console/session_factory.py). Judge falls back to the resolved - # coordinator alias when judge.model is empty *or* not a registered - # alias — judge.model is alias-only (matches IntentJudge.__init__), - # so an unknown value is operator misconfiguration that the judge - # itself silently inherits the session model on. + # Coordinator fallback chain mirrors console/session_factory.py:109-110: + # explicit ``coordinator.model_alias`` → ``model.default_alias`` + # (admin-managed) → ``registry.default`` (the config.toml default that + # session_factory falls through to via ``registry.default`` when both + # ConfigStore keys are unset). Without the registry tier the + # placeholder lies whenever an operator never set ``model.default_alias`` + # in the admin UI — new coordinator sessions will still run on + # ``registry.default``. Judge falls back to the resolved coordinator + # alias when ``judge.model`` is empty *or* not a registered alias — + # judge.model is alias-only (matches IntentJudge.__init__), so an + # unknown value is operator misconfiguration that the judge itself + # silently inherits the session model on. if not coordinator_default_alias or coordinator_default_alias not in enabled_aliases: coordinator_default_alias = default_alias + if not coordinator_default_alias: + coord_registry = getattr(request.app.state, "coord_registry", None) + registry_default = getattr(coord_registry, "default", "") if coord_registry else "" + if registry_default and registry_default in enabled_aliases: + coordinator_default_alias = registry_default if not judge_default_alias or judge_default_alias not in enabled_aliases: judge_default_alias = coordinator_default_alias return JSONResponse( diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 04979434..3506b4be 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -1813,7 +1813,7 @@ function _resolveModelLabel(alias, models) { return m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")"; } } - return alias; + return ""; } // Populate Model + Judge Model dropdowns from /v1/api/models — same