From 4a2b99b30c0d2a0177d78e4bf6d94cc2fe05c55c Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Sat, 8 Aug 2026 08:46:36 -0700 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20settings=20panels=20render=20?= =?UTF-8?q?=E2=80=94=20model/options,=20auxiliary,=20config/schema=20REST?= =?UTF-8?q?=20(QA=20round=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings > Model panel was stuck on skeleton loaders: it awaits four REST routes in a Promise.all and three of them 404'd on serve (/api/model/options, /api/model/auxiliary, /api/config/schema), so the whole panel never left its loading state. Other panels polled dead routes too (/api/cron/jobs 54x, /api/audio/elevenlabs/voices). Added the settings-panel REST surface: - /api/model/options — session-independent catalog (same as the WS RPC's config fallback), so the provider/model dropdowns populate. - /api/model/auxiliary — {main, tasks:[]}; the panel renders 'all helper tasks use the main model', which is the true state (no per-task override wired yet). - /api/config/schema — {fields:{}}; no dynamic schema in this backend, so the Advanced tab's generated rows degrade to none while the structured panels render from their own fields. - /api/cron/jobs {jobs:[]}, /api/audio/elevenlabs/voices {voices:[]} — empty but valid, stopping the 404 poll spam and letting those panels settle. - /api/sessions/{id} — single-session detail from the saved transcript. Verified live: the Model settings panel fully renders — provider (Anthropic Claude) + model (claude-sonnet-4-6) selects, Apply, Reasoning, and the full Auxiliary-models list (Vision/Web extract/Compression/Skills hub/Approval/ MCP/Title gen). 54 desktop pytest cases green. Co-Authored-By: Claude Fable 5 --- src/server/desktop_serve.py | 60 +++++++++++++++++++++++++++ tests/server/test_desktop_sessions.py | 39 +++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/server/desktop_serve.py b/src/server/desktop_serve.py index 3aed5198..c556e842 100644 --- a/src/server/desktop_serve.py +++ b/src/server/desktop_serve.py @@ -166,6 +166,60 @@ async def model_info(request: Request) -> Response: except Exception: # noqa: BLE001 — inspection endpoint, degrade soft return JSONResponse({"provider": None, "model": None}) + async def model_options_rest(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.server.desktop_gateway_methods import _catalog_from_config + + import asyncio as _asyncio + return JSONResponse(await _asyncio.to_thread(_catalog_from_config)) + + async def model_auxiliary(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + # Auxiliary task-model assignments aren't wired to a control yet; the + # panel reads `main` and an (empty) task list and renders "using the + # main model for everything", which is the true state. + from src.config import get_default_provider, get_provider_config + + try: + provider = get_default_provider() + model = (get_provider_config(provider) or {}).get("default_model") + except Exception: # noqa: BLE001 + provider, model = None, None + return JSONResponse({"main": {"model": model, "provider": provider}, "tasks": []}) + + async def config_schema(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + # No dynamic config schema in this backend — the structured settings + # panels render from their own known fields; the schema only drives the + # Advanced tab's generated rows, which degrade to none. + return JSONResponse({"fields": {}, "category_order": []}) + + async def cron_jobs(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return JSONResponse({"jobs": []}) + + async def elevenlabs_voices(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return JSONResponse({"voices": []}) + + async def session_detail(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.server.desktop_sessions import load_session_messages + + sid = request.path_params["session_id"] + found = load_session_messages(state.saved_sessions_dir(), sid) + if found is None: + return JSONResponse({"error": "session not found"}, status_code=404) + return JSONResponse({"session_id": found["session_id"], + "messages": found["messages"], + "message_count": found["message_count"]}) + def _sessions_slice(request: Request, *, profile_tag: str = "default") -> dict[str, Any]: """One filtered slice of the saved-session list (shared by the profile-scoped route and the batched sidebar).""" @@ -334,6 +388,12 @@ async def not_found(request: Request) -> Response: Route("/api/profiles/sessions", profile_sessions), Route("/api/profiles/sessions/sidebar", sidebar_sessions), Route("/api/model/info", model_info), + Route("/api/model/options", model_options_rest), + Route("/api/model/auxiliary", model_auxiliary), + Route("/api/config/schema", config_schema), + Route("/api/cron/jobs", cron_jobs), + Route("/api/audio/elevenlabs/voices", elevenlabs_voices), + Route("/api/sessions/{session_id}", session_detail), Route("/api/audio/transcribe", audio_transcribe, methods=["POST"]), Route("/", index), WebSocketRoute("/api/ws", gateway_ws), diff --git a/tests/server/test_desktop_sessions.py b/tests/server/test_desktop_sessions.py index 19c226f6..7e82d8b1 100644 --- a/tests/server/test_desktop_sessions.py +++ b/tests/server/test_desktop_sessions.py @@ -141,6 +141,45 @@ def test_model_info_reports_defaults( assert body == {"provider": "anthropic", "model": "claude-fable-5"} +def test_settings_panel_rest_routes( + rest: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The Model/Voice/Advanced settings panels each await a REST route; a 404 + on any leaves the panel stuck on skeletons. Assert the shapes they read.""" + monkeypatch.setattr("src.config.get_default_provider", lambda: "anthropic") + monkeypatch.setattr("src.config.get_provider_config", + lambda n: {"default_model": "claude-sonnet-4-6"}) + + options = rest.get("/api/model/options", headers=AUTH).json() + assert "providers" in options and len(options["providers"]) > 5 + + aux = rest.get("/api/model/auxiliary", headers=AUTH).json() + assert aux["main"] == {"model": "claude-sonnet-4-6", "provider": "anthropic"} + assert aux["tasks"] == [] + + schema = rest.get("/api/config/schema", headers=AUTH).json() + assert schema == {"fields": {}, "category_order": []} + + assert rest.get("/api/cron/jobs", headers=AUTH).json() == {"jobs": []} + assert rest.get("/api/audio/elevenlabs/voices", headers=AUTH).json() == {"voices": []} + + # All are token-gated. + for path in ("/api/model/options", "/api/model/auxiliary", "/api/config/schema", + "/api/cron/jobs", "/api/audio/elevenlabs/voices"): + assert rest.get(path).status_code == 401 + + +def test_session_detail_route(rest: TestClient, tmp_path: Path) -> None: + _write_session( + tmp_path / "sessions", "chat-x", preview="hi", count=1, + messages=[{"role": "user", "content": "hi"}], + ) + body = rest.get("/api/sessions/chat-x", headers=AUTH).json() + assert body["session_id"] == "chat-x" + assert body["message_count"] == 1 + assert rest.get("/api/sessions/missing", headers=AUTH).status_code == 404 + + def test_profile_sessions_slice_filters_sources( rest: TestClient, tmp_path: Path ) -> None: