mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-24 12:54:48 -06:00
75d0b81b07
Bundles the click-around polish on the console admin UX. Home composer + schedule modals - /v1/api/models now exposes coordinator_default_alias + judge_default_alias, resolved through the same chain console/session_factory.py uses. Both the home composer's MODEL / JUDGE MODEL placeholders and the schedule create / edit modal model placeholders rewrite to "Default — alias (model)" once the API responds. The `models_changed` SSE refresh keeps placeholders current as operators edit per-role assignments. - Composer.setOptionPlaceholder added so callers can update just the first option's text without disturbing the rest of the choice list. Admin → Models → Roles - Channel adapter row added (channels.default_model_alias) — the migration to the Roles sub-tab missed it. Key added to _MODEL_AFFECTING_SETTING_KEYS so edits fire the SSE refresh, and to the settings-tab roleKeys skip-list so it only renders in one place. - Plan/Task agent rows now display "(inherit)" instead of the misleading "(default — <alias>)" — those roles cascade through plan_model → agent_model → session model, not a single concrete default. - coordinator.reasoning_effort accepts "" (inherit), matching model.plan_effort / model.task_effort. - Blank options in each role's MODEL select now match the "alias (model)" shape used by the other rows. Toggle-switch component - New .toggle-switch component (visually-hidden native checkbox + styled track + label). 40×22 hit target meets WCAG 2.5.5 (AAA), inset ring on the off state for ≥1.5:1 contrast against the modal surface. - .toggle-stack groups toggles in a column with .toggle-group-divider for conceptual grouping (used in the Add Model modal between "Active" and the paired Reasoning toggles). - .toggle--flush modifier zeroes the default top margin for toggles that sit flush against a heading or a dynamically-rendered row. Sweep — every admin-modal boolean checkbox is now a toggle: schedule (cs/es-autoapprove, es-enabled), policy (ep/epp-enabled), tool-mode (ctm/etm-default), skill (csk/esk-auto-approve, csk/esk-enabled), MCP (mcp-auto-approve, mcp-enabled), Add Model (Active, surface-persisted- reasoning, replay-reasoning), judge bool settings (cancel_on_approval et al.), and the user-roles-modal role assignment list. The two ogp-cred / eogp-cred inline credential checkboxes stay as compact inline boxes since they sit beside text inputs in tight horizontal rows. Add Model modal — the "Enabled" toggle promoted to "Active" and moved to the very top of the form. Tooltip explains it gates dropdown visibility without removing the definition. MCP authorization — the three radio buttons replaced with a vertical .segmented-control option list. Selected row paints --accent-dim plus a filled .segmented-indicator; focus ring uses --accent so it stays visible on the currently-selected option. Role permissions modal — the 19 permission checkboxes are now .toggle-switch.perm-toggle (monospace lowercase identifiers preserved). The permissions are split into Scopes / Admin / Workstreams & Tools sections under caps-styled section headers so the row-flow grid no longer slices `admin.*` mid-column. Judge bool toggles use a static "Enabled" caption rather than flipping text on `.checked`; flipping lagged 50–300 ms behind the slider position because the caption was sourced from the post-save reload. CSS cleanup — dead `.admin-checkbox` / `.perm-checkbox` rules removed. Specificity audit (scripts/css_specificity_audit.py) returns no conflicts on any new component class. Tests — 525 pass on the affected slices; new tests/test_console_available_ models.py pins each branch of the resolution chain in /v1/api/models so the home composer placeholder stays correct as precedence rules evolve.
276 lines
9.5 KiB
Python
276 lines
9.5 KiB
Python
"""``models_changed`` SSE fanout coverage.
|
|
|
|
The console pushes a ``models_changed`` cluster event whenever a model
|
|
definition is created / updated / deleted / reloaded, or whenever a
|
|
setting in :data:`turnstone.console.server._MODEL_AFFECTING_SETTING_KEYS`
|
|
is updated or reset. Connected browsers refetch ``/v1/api/models`` on
|
|
receipt so the home composer dropdown + admin Models → Roles sub-tab
|
|
reflect alias edits without a manual reload.
|
|
|
|
These tests pin two contracts:
|
|
|
|
- every model-definition CRUD path emits exactly one ``models_changed``
|
|
fanout (so the browser stays in sync with the DB);
|
|
- settings PUT / DELETE only emit the fanout when the key is
|
|
model-affecting — unrelated keys (e.g. ``session.retention_days``)
|
|
must not trigger spurious dropdown re-renders across the cluster.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.routing import Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from tests._coord_test_helpers import _AuthMiddleware
|
|
from turnstone.console.server import (
|
|
_MODEL_AFFECTING_SETTING_KEYS,
|
|
admin_create_model_definition,
|
|
admin_delete_model_definition,
|
|
admin_delete_setting,
|
|
admin_model_reload,
|
|
admin_update_model_definition,
|
|
admin_update_setting,
|
|
)
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path: Any) -> SQLiteBackend:
|
|
return SQLiteBackend(str(tmp_path / "models_changed.db"))
|
|
|
|
|
|
def _seed(storage: SQLiteBackend, *, definition_id: str, alias: str) -> None:
|
|
storage.create_model_definition(
|
|
definition_id=definition_id,
|
|
alias=alias,
|
|
model="model-x",
|
|
provider="openai-compatible",
|
|
base_url="http://localhost:8000/v1",
|
|
api_key="sk-test",
|
|
context_window=8192,
|
|
capabilities="{}",
|
|
enabled=True,
|
|
created_by="admin",
|
|
)
|
|
|
|
|
|
def _make_client(storage: SQLiteBackend) -> tuple[TestClient, MagicMock]:
|
|
"""Build a TestClient + return the stub collector for assertion.
|
|
|
|
Wires the four model-definition CRUD/reload routes plus the two
|
|
settings mutation routes. Collector is a MagicMock so each
|
|
``emit_models_changed`` call lands as a recorded call without
|
|
spinning up the full SSE listener queue.
|
|
"""
|
|
app = Starlette(
|
|
routes=[
|
|
Route(
|
|
"/v1/api/admin/model-definitions",
|
|
admin_create_model_definition,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/v1/api/admin/model-definitions/reload",
|
|
admin_model_reload,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/v1/api/admin/model-definitions/{definition_id}",
|
|
admin_update_model_definition,
|
|
methods=["PUT"],
|
|
),
|
|
Route(
|
|
"/v1/api/admin/model-definitions/{definition_id}",
|
|
admin_delete_model_definition,
|
|
methods=["DELETE"],
|
|
),
|
|
Route(
|
|
"/v1/api/admin/settings/{key:path}",
|
|
admin_update_setting,
|
|
methods=["PUT"],
|
|
),
|
|
Route(
|
|
"/v1/api/admin/settings/{key:path}",
|
|
admin_delete_setting,
|
|
methods=["DELETE"],
|
|
),
|
|
],
|
|
middleware=[Middleware(_AuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
app.state.coord_registry = None # CRUD endpoints handle this gracefully
|
|
collector = MagicMock()
|
|
collector.get_all_nodes.return_value = []
|
|
app.state.collector = collector
|
|
app.state.proxy_client = MagicMock()
|
|
app.state.config_store = MagicMock()
|
|
client = TestClient(app)
|
|
client.headers.update(
|
|
{
|
|
"X-Test-User": "admin",
|
|
"X-Test-Perms": "admin.models,admin.settings",
|
|
}
|
|
)
|
|
return client, collector
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Model-definition CRUD endpoints fan out ``models_changed``
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_emits_models_changed(storage: SQLiteBackend) -> None:
|
|
client, collector = _make_client(storage)
|
|
resp = client.post(
|
|
"/v1/api/admin/model-definitions",
|
|
json={
|
|
"alias": "fast",
|
|
"model": "fast-model",
|
|
"provider": "openai-compatible",
|
|
"base_url": "http://localhost:9000/v1",
|
|
"api_key": "sk-x",
|
|
"context_window": 4096,
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 1
|
|
|
|
|
|
def test_update_emits_models_changed(storage: SQLiteBackend) -> None:
|
|
_seed(storage, definition_id="m1", alias="local")
|
|
client, collector = _make_client(storage)
|
|
resp = client.put(
|
|
"/v1/api/admin/model-definitions/m1",
|
|
json={"model": "swapped-model"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 1
|
|
|
|
|
|
def test_update_with_empty_body_does_not_emit(storage: SQLiteBackend) -> None:
|
|
"""Empty-body PUT writes no rows + skips the registry refresh — no
|
|
SSE fanout either, since nothing actually changed."""
|
|
_seed(storage, definition_id="m1", alias="local")
|
|
client, collector = _make_client(storage)
|
|
resp = client.put("/v1/api/admin/model-definitions/m1", json={})
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 0
|
|
|
|
|
|
def test_delete_emits_models_changed(storage: SQLiteBackend) -> None:
|
|
_seed(storage, definition_id="m1", alias="local")
|
|
client, collector = _make_client(storage)
|
|
resp = client.delete("/v1/api/admin/model-definitions/m1")
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 1
|
|
|
|
|
|
def test_reload_emits_models_changed(storage: SQLiteBackend) -> None:
|
|
_seed(storage, definition_id="m1", alias="local")
|
|
client, collector = _make_client(storage)
|
|
resp = client.post("/v1/api/admin/model-definitions/reload")
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings PUT / DELETE only emit for model-affecting keys
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Pinned snapshot of the role-related keys we expect the allowlist to
|
|
# cover today. The frozenset itself is asserted further down so a
|
|
# stray addition doesn't silently bypass coverage.
|
|
_EXPECTED_AFFECTING_KEYS = frozenset(
|
|
{
|
|
"model.default_alias",
|
|
"model.plan_alias",
|
|
"model.plan_effort",
|
|
"model.task_alias",
|
|
"model.task_effort",
|
|
"coordinator.model_alias",
|
|
"coordinator.reasoning_effort",
|
|
"judge.model",
|
|
"channels.default_model_alias",
|
|
}
|
|
)
|
|
|
|
|
|
def _value_for_key(key: str) -> str:
|
|
"""Return a registry-valid value for ``key``.
|
|
|
|
``reasoning_effort`` keys have a fixed choice list; alias-shaped
|
|
keys accept arbitrary strings. Avoids per-key custom payloads.
|
|
"""
|
|
if (
|
|
key.endswith("reasoning_effort")
|
|
or key.endswith("plan_effort")
|
|
or key.endswith("task_effort")
|
|
):
|
|
return "low"
|
|
return "anything"
|
|
|
|
|
|
def test_affecting_keys_set_matches_expected() -> None:
|
|
"""Lock in the allowlist so an unintentional removal is caught."""
|
|
assert _MODEL_AFFECTING_SETTING_KEYS == _EXPECTED_AFFECTING_KEYS
|
|
|
|
|
|
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
|
|
def test_settings_put_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
|
|
client, collector = _make_client(storage)
|
|
resp = client.put(
|
|
f"/v1/api/admin/settings/{key}",
|
|
json={"value": _value_for_key(key)},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 1
|
|
|
|
|
|
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
|
|
def test_settings_delete_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
|
|
client, collector = _make_client(storage)
|
|
# Seed a row so DELETE has something to remove (otherwise 404).
|
|
client.put(
|
|
f"/v1/api/admin/settings/{key}",
|
|
json={"value": _value_for_key(key)},
|
|
)
|
|
collector.emit_models_changed.reset_mock()
|
|
resp = client.delete(f"/v1/api/admin/settings/{key}")
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 1
|
|
|
|
|
|
def test_settings_put_does_not_emit_for_unrelated_key(
|
|
storage: SQLiteBackend,
|
|
) -> None:
|
|
"""Updating a non-model setting (here: a session retention knob)
|
|
must not trigger a cluster-wide dropdown refresh."""
|
|
client, collector = _make_client(storage)
|
|
resp = client.put(
|
|
"/v1/api/admin/settings/session.retention_days",
|
|
json={"value": 30},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 0
|
|
|
|
|
|
def test_settings_delete_does_not_emit_for_unrelated_key(
|
|
storage: SQLiteBackend,
|
|
) -> None:
|
|
client, collector = _make_client(storage)
|
|
client.put(
|
|
"/v1/api/admin/settings/session.retention_days",
|
|
json={"value": 30},
|
|
)
|
|
collector.emit_models_changed.reset_mock()
|
|
resp = client.delete("/v1/api/admin/settings/session.retention_days")
|
|
assert resp.status_code == 200, resp.text
|
|
assert collector.emit_models_changed.call_count == 0
|