diff --git a/src/cli.py b/src/cli.py index 175dfdd2..e318ca57 100644 --- a/src/cli.py +++ b/src/cli.py @@ -121,6 +121,9 @@ def main(): if token == 'serve': from src.entrypoints.serve_cli import run_serve_subcommand return run_serve_subcommand(rest) + if token == 'desktop': + from src.entrypoints.desktop_cli import run_desktop_subcommand + return run_desktop_subcommand(rest) if token == 'tui': return _run_tui_subcommand(rest) if token == 'migrate': diff --git a/src/entrypoints/desktop_cli.py b/src/entrypoints/desktop_cli.py new file mode 100644 index 00000000..439ebeb6 --- /dev/null +++ b/src/entrypoints/desktop_cli.py @@ -0,0 +1,98 @@ +"""``clawcodex desktop`` — launch the ClawCodex Desktop app. + +Dev-oriented launcher for the Electron shell in ``ui-desktop/``: it resolves +the checkout this CLI is running from, makes sure the app's npm deps exist, +and hands off to ``npm run dev`` (Vite renderer + Electron main, which spawns +`clawcodex serve` as its backend). The spawned app is pointed back at THIS +checkout via ``CLAWCODEX_DESKTOP_BACKEND_ROOT`` so the backend it boots is the +code you're sitting in, not whatever else is on PATH. + +Packaged-app launching (installed .app/.exe) arrives with the packaging +stage; this entry covers the source-checkout path the TUI's ``clawcodex`` +command already serves. + +Usage:: + + clawcodex desktop [--install] [--no-dev] + +--install run `npm ci` even if node_modules exists +--no-dev build once and launch electron directly (`npm run start`) +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def repo_root() -> Path: + """The clawcodex checkout this module runs from.""" + return Path(__file__).resolve().parents[2] + + +def desktop_dir(root: Path | None = None) -> Path: + return (root or repo_root()) / "ui-desktop" + + +def launch_env(root: Path) -> dict[str, str]: + """Environment for the app process: pin the backend to this checkout.""" + env = dict(os.environ) + env.setdefault("CLAWCODEX_DESKTOP_CLAWCODEX_ROOT", str(root)) + return env + + +def build_launch_plan(app_dir: Path, *, install: bool, dev: bool) -> list[list[str]]: + """The npm commands to run, in order. Pure for testing.""" + plan: list[list[str]] = [] + if install or not (app_dir / "node_modules").is_dir(): + plan.append(["npm", "ci"]) + plan.append(["npm", "run", "dev"] if dev else ["npm", "run", "start"]) + return plan + + +def run_desktop_subcommand(argv: list[str]) -> int: + parser = argparse.ArgumentParser( + prog="clawcodex desktop", + description="Launch the ClawCodex Desktop app from this checkout.", + ) + parser.add_argument("--install", action="store_true", + help="Reinstall ui-desktop npm deps first (npm ci).") + parser.add_argument("--no-dev", action="store_true", dest="no_dev", + help="Build once and launch Electron directly instead " + "of the dev server.") + args = parser.parse_args(argv) + + root = repo_root() + app_dir = desktop_dir(root) + if not (app_dir / "package.json").is_file(): + print(f"desktop: no ui-desktop app at {app_dir} — run from a full " + "clawcodex checkout (git pull to get the desktop app).", + file=sys.stderr) + return 2 + if shutil.which("npm") is None: + print("desktop: npm not found on PATH — install Node.js 22+ first.", + file=sys.stderr) + return 2 + + env = launch_env(root) + for cmd in build_launch_plan(app_dir, install=args.install, dev=not args.no_dev): + print(f"desktop: {' '.join(cmd)} (in {app_dir})", file=sys.stderr) + try: + result = subprocess.run(cmd, cwd=str(app_dir), env=env) + except KeyboardInterrupt: + return 0 + if result.returncode != 0: + return result.returncode + return 0 + + +__all__ = ["build_launch_plan", "desktop_dir", "launch_env", "repo_root", + "run_desktop_subcommand"] + + +if __name__ == "__main__": + raise SystemExit(run_desktop_subcommand(sys.argv[1:])) diff --git a/src/server/desktop_gateway_methods.py b/src/server/desktop_gateway_methods.py index e48d24b4..361bc1b5 100644 --- a/src/server/desktop_gateway_methods.py +++ b/src/server/desktop_gateway_methods.py @@ -39,10 +39,16 @@ CONTROL_TIMEOUT_S = 30.0 +# GUI↔backend contract version. The ClawCodex ladder restarts at 1 (the +# reference implementation was at 5); bump when the desktop starts requiring +# a capability this server ships (the renderer's REQUIRED_BACKEND_CONTRACT in +# ui-desktop/src/store/updates.ts must match). +DESKTOP_CONTRACT = 1 + def _init_session_info(init: dict[str, Any]) -> dict[str, Any]: """system/init frame → the ``session.info`` payload the renderer reads.""" - payload: dict[str, Any] = {"running": False} + payload: dict[str, Any] = {"running": False, "desktop_contract": DESKTOP_CONTRACT} cwd = init.get("cwd") if cwd: payload["cwd"] = cwd @@ -153,6 +159,8 @@ async def _route(self, frame: dict[str, Any]) -> None: mode = frame.get("permission_mode") if mode: await self._broadcast("session.info", {"approval_mode": mode, "running": False}) + # Turn end persisted the transcript — nudge sidebars to refresh. + await self._broadcast("sessions.changed", {}) async def _route_ask(self, frame: dict[str, Any]) -> None: rid = str(frame.get("request_id") or "") @@ -274,7 +282,10 @@ def __init__(self, websocket: WebSocket, state: DesktopServeState) -> None: async def on_open(self) -> None: for session in self.state.sessions.values(): session.sockets.add(self.websocket) - await send_event(self.websocket, "gateway.ready", {"app": "clawcodex"}) + # change_events: sessions.changed is pushed after every turn, so the + # renderer can demote its sidebar polling. + await send_event(self.websocket, "gateway.ready", + {"app": "clawcodex", "change_events": True}) async def on_close(self) -> None: for session in self.state.sessions.values(): @@ -335,18 +346,25 @@ async def session_create(self, params: dict[str, Any]) -> dict[str, Any]: async def session_resume(self, params: dict[str, Any]) -> dict[str, Any]: wanted = str(params.get("session_id") or "") or None session = await self._create(params.get("cwd"), wanted) - return { + response: dict[str, Any] = { "session_id": session.session_id, "stored_session_id": wanted or session.session_id, "resumed": wanted or session.session_id, - # Transcript hydration ships with the sessions REST stage; the - # agent's context IS restored (resume control), history paints - # lazily once /api/sessions lands. "message_count": 0, "messages": [], - "messages_omitted": True, "info": _init_session_info(session.init_info), } + omit = bool(params.get("omit_messages") or params.get("lazy")) + if wanted and not omit: + from src.server.desktop_sessions import load_session_messages + + stored = load_session_messages(self.state.saved_sessions_dir(), wanted) + if stored is not None: + response["messages"] = stored["messages"] + response["message_count"] = stored["message_count"] + elif wanted and omit: + response["messages_omitted"] = True + return response async def session_activate(self, params: dict[str, Any]) -> dict[str, Any]: if params.get("session_id"): diff --git a/src/server/desktop_serve.py b/src/server/desktop_serve.py index 6c109b69..d98d1517 100644 --- a/src/server/desktop_serve.py +++ b/src/server/desktop_serve.py @@ -22,7 +22,9 @@ from __future__ import annotations import hmac +import logging from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Awaitable, Callable from starlette.applications import Starlette @@ -31,6 +33,8 @@ from starlette.routing import Route, WebSocketRoute from starlette.websockets import WebSocket +logger = logging.getLogger(__name__) + @dataclass class DesktopServeState: @@ -43,6 +47,15 @@ class DesktopServeState: protocol_version: str # session_id -> live DesktopSession (created lazily by the gateway). sessions: dict[str, Any] = field(default_factory=dict) + # Saved-transcript dir override (tests); default resolves per request. + sessions_dir: Path | None = None + + def saved_sessions_dir(self) -> Path: + if self.sessions_dir is not None: + return self.sessions_dir + from src.utils.clawcodex_dirs import get_sessions_dir + + return Path(get_sessions_dir()) async def shutdown(self) -> None: """Best-effort shutdown of every live agent session.""" @@ -102,6 +115,158 @@ async def config(request: Request) -> Response: return JSONResponse(redact_secrets(load_config())) + def _int_param(request: Request, name: str, default: int) -> int: + try: + return int(request.query_params.get(name, default)) + except (TypeError, ValueError): + return default + + async def sessions_list(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.server.desktop_sessions import list_session_rows + + result = list_session_rows( + state.saved_sessions_dir(), + limit=_int_param(request, "limit", 20), + offset=_int_param(request, "offset", 0), + min_messages=_int_param(request, "min_messages", 0), + ) + live = { + getattr(s, "session_id", None) for s in state.sessions.values() + } + for row in result["sessions"]: + if row["id"] in live: + row["is_active"] = True + return JSONResponse(result) + + async def session_messages(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 + + found = load_session_messages( + state.saved_sessions_dir(), request.path_params["session_id"] + ) + if found is None: + return JSONResponse({"error": "session not found"}, status_code=404) + return JSONResponse(found) + + async def model_info(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.config import get_default_provider, get_provider_config + + try: + provider = get_default_provider() + cfg = get_provider_config(provider) or {} + return JSONResponse( + {"provider": provider, "model": cfg.get("default_model")} + ) + except Exception: # noqa: BLE001 — inspection endpoint, degrade soft + return JSONResponse({"provider": None, "model": None}) + + 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).""" + from src.server.desktop_sessions import list_session_rows + + params = request.query_params + source = params.get("source") or None + exclude = { + s for s in (params.get("exclude_sources") or "").split(",") if s + } + result = list_session_rows( + state.saved_sessions_dir(), + limit=_int_param(request, "limit", 40), + offset=_int_param(request, "offset", 0), + min_messages=_int_param(request, "min_messages", 0), + ) + rows = [ + {**row, "profile": profile_tag} + for row in result["sessions"] + if (source is None or row.get("source") == source) + and row.get("source") not in exclude + ] + return {**result, "sessions": rows} + + async def profile_sessions(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + # Single-profile serve: every row belongs to "default"; the profile + # query param only scopes recency windows, which is a no-op here. + return JSONResponse(_sessions_slice(request)) + + async def sidebar_sessions(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.server.desktop_sessions import list_session_rows + + params = request.query_params + exclude = { + s for s in (params.get("recents_exclude") or "").split(",") if s + } + try: + recents_limit = max(1, int(params.get("recents_limit", 20))) + except ValueError: + recents_limit = 20 + listing = list_session_rows( + state.saved_sessions_dir(), limit=recents_limit, min_messages=1 + ) + recents = [ + {**row, "profile": "default"} + for row in listing["sessions"] + if row.get("source") not in exclude + ] + # cron/messaging surfaces don't exist on this backend yet — empty + # slices are the documented degrade shape. + return JSONResponse( + { + "recents": {"sessions": recents}, + "cron": {"sessions": []}, + "messaging": {"sessions": []}, + } + ) + + def _default_profile_info() -> dict[str, Any]: + from src.config import get_default_provider, get_provider_config, load_config + from src.utils.clawcodex_dirs import get_user_config_dir + + model = provider = None + has_env = False + try: + provider = get_default_provider() + model = (get_provider_config(provider) or {}).get("default_model") + has_env = bool((load_config() or {}).get("env")) + except Exception: # noqa: BLE001 — profile card degrades soft + pass + return { + "name": "default", + "is_default": True, + "path": str(get_user_config_dir()), + "model": model, + "provider": provider, + "has_env": has_env, + "skill_count": 0, + } + + async def profiles(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return JSONResponse({"profiles": [_default_profile_info()]}) + + async def profiles_active(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return JSONResponse({"active": "default", "current": "default"}) + + async def config_defaults(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.config import get_default_config + + return JSONResponse(redact_secrets(get_default_config())) + async def index(_: Request) -> Response: # dashboard-token.ts scrapes this global from the served page to adopt # a running backend's token. Serve nothing else here — the desktop @@ -127,12 +292,31 @@ async def gateway_ws(websocket: WebSocket) -> None: await handle_gateway_socket(websocket, state) + async def not_found(request: Request) -> Response: + # Named 404s: the remaining REST surface is being built stage by + # stage — logging which paths the shell actually asks for is the + # to-do list (and the fastest way to spot a wrong route). + # warning: the default logging setup surfaces WARNING+ on stderr, and + # an unimplemented route IS a warning during the staged port. + logger.warning("serve: 404 %s %s", request.method, request.url.path) + return JSONResponse({"error": "not found"}, status_code=404) + routes = [ Route("/api/health", health), Route("/api/status", status), Route("/api/config", config), + Route("/api/config/defaults", config_defaults), + Route("/api/sessions", sessions_list), + Route("/api/sessions/{session_id}/messages", session_messages), + Route("/api/profiles", profiles), + Route("/api/profiles/active", profiles_active), + Route("/api/profiles/sessions", profile_sessions), + Route("/api/profiles/sessions/sidebar", sidebar_sessions), + Route("/api/model/info", model_info), Route("/", index), WebSocketRoute("/api/ws", gateway_ws), + Route("/{rest:path}", not_found, + methods=["GET", "POST", "PUT", "PATCH", "DELETE"]), ] return Starlette(routes=routes) diff --git a/src/server/desktop_sessions.py b/src/server/desktop_sessions.py new file mode 100644 index 00000000..265046d4 --- /dev/null +++ b/src/server/desktop_sessions.py @@ -0,0 +1,136 @@ +"""Saved-session listing + transcript hydration for ``clawcodex serve``. + +The sidebar and resume both read the durable per-session files under +``/sessions/.json`` (written by the agent core every turn end; +transport-independent, so TUI- and desktop-created sessions appear in the +same list). Shapes here mirror the renderer's expectations +(``ui-desktop/src/types/clawcodex.ts``): ``SessionInfo`` rows for the sidebar, +``SessionMessage`` rows for transcripts. + +Pure functions over the filesystem — no server state — for easy testing. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Session ids are uuid-ish/token-ish path segments minted by us; anything else +# (traversal, separators) is refused before touching the filesystem. +_ID_OK = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") + + +def _safe_id(session_id: str) -> str | None: + if session_id and set(session_id) <= _ID_OK: + return session_id + return None + + +def _read_session_file(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _row_from_file(path: Path, data: dict[str, Any]) -> dict[str, Any]: + """One sidebar ``SessionInfo`` row from a saved-session file.""" + session_id = str(data.get("session_id") or path.stem) + updated_at = data.get("updated_at") + preview = str(data.get("preview") or "") + name = data.get("name") + return { + "id": session_id, + "title": str(name) if name else preview[:80], + "preview": preview, + "source": str(data.get("mode") or "desktop"), + "started_at": updated_at, + "last_active": updated_at, + "message_count": int(data.get("message_count") or 0), + "model": data.get("model"), + "cwd": data.get("cwd"), + "is_active": False, + } + + +def list_session_rows( + sessions_dir: Path, + *, + limit: int = 20, + offset: int = 0, + min_messages: int = 0, +) -> dict[str, Any]: + """Paginated sidebar listing, newest-first by file mtime.""" + try: + files = sorted( + sessions_dir.glob("*.json"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + except OSError: + files = [] + + rows: list[dict[str, Any]] = [] + for path in files: + data = _read_session_file(path) + if data is None: + continue + row = _row_from_file(path, data) + if row["message_count"] < min_messages: + continue + rows.append(row) + + window = rows[offset : offset + limit] if limit > 0 else rows[offset:] + return { + "sessions": window, + "total": len(rows), + "limit": limit, + "offset": offset, + } + + +def _display_kind(role: str, content: Any) -> str | None: + """Hide plumbing rows the renderer should not paint as bubbles.""" + if role == "user" and isinstance(content, str) and content.lstrip().startswith(""): + return "hidden" + return None + + +def load_session_messages(sessions_dir: Path, session_id: str) -> dict[str, Any] | None: + """Transcript for one saved session: ``{messages, message_count}``. + + Messages pass through in their stored shape (string or content-block list) + — the renderer already narrows both, live and rehydrated. + """ + safe = _safe_id(session_id) + if safe is None: + return None + data = _read_session_file(sessions_dir / f"{safe}.json") + if data is None: + return None + conversation = data.get("conversation") or {} + raw = conversation.get("messages") if isinstance(conversation, dict) else conversation + messages: list[dict[str, Any]] = [] + for entry in raw or []: + if not isinstance(entry, dict): + continue + role = str(entry.get("role") or "user") + content = entry.get("content") + message: dict[str, Any] = {"role": role, "content": content} + kind = _display_kind(role, content) + if kind: + message["display_kind"] = kind + messages.append(message) + return { + "messages": messages, + "message_count": len(messages), + "session_id": str(data.get("session_id") or safe), + } + + +__all__ = ["list_session_rows", "load_session_messages"] diff --git a/tests/server/test_desktop_gateway.py b/tests/server/test_desktop_gateway.py index 24103da6..e04c96ab 100644 --- a/tests/server/test_desktop_gateway.py +++ b/tests/server/test_desktop_gateway.py @@ -78,6 +78,20 @@ def __init__(self) -> None: async def send_to_agent(self, frame: dict) -> None: self.inbound.append(frame) + if frame.get("type") == "control_request": + request = frame.get("request") or {} + if request.get("subtype") == "resume": + await self.queue.put( + { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": frame.get("request_id"), + "response": {"ok": True}, + }, + } + ) + return if frame.get("type") == "user": # One scripted streamed turn per user message. await self.queue.put( @@ -266,6 +280,49 @@ def reply_frames(): assert reply["response"]["updatedInput"] == {"command": "rm -rf /tmp/x"} +def test_resume_hydrates_saved_transcript(tmp_path: Path) -> None: + state, agents = _fake_state(tmp_path) + sessions_dir = tmp_path / "saved" + sessions_dir.mkdir() + state.sessions_dir = sessions_dir + (sessions_dir / "old-chat.json").write_text( + json.dumps( + { + "session_id": "old-chat", + "preview": "hello?", + "message_count": 2, + "conversation": { + "messages": [ + {"role": "user", "content": "hello?"}, + {"role": "assistant", + "content": [{"type": "text", "text": "hi back"}]}, + ] + }, + } + ), + encoding="utf-8", + ) + + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() + events: list[dict] = [] + _rpc(ws, 1, "session.resume", {"session_id": "old-chat"}) + result = _drain_for_response(ws, 1, events)["result"] + + assert result["resumed"] == "old-chat" + assert result["stored_session_id"] == "old-chat" + assert result["session_id"] == "fake-1" # fresh runtime session + assert result["message_count"] == 2 + assert [m["role"] for m in result["messages"]] == ["user", "assistant"] + # The agent got the resume control with the stored id. + resumes = [ + f for f in agents[0].inbound + if f.get("type") == "control_request" + and (f.get("request") or {}).get("subtype") == "resume" + ] + assert resumes and resumes[0]["request"]["session_id"] == "old-chat" + + # ─── real-spawn tier (provider/tool stack stubbed, agent real) ─────────────── diff --git a/tests/server/test_desktop_serve.py b/tests/server/test_desktop_serve.py index 8a76df26..1f67470c 100644 --- a/tests/server/test_desktop_serve.py +++ b/tests/server/test_desktop_serve.py @@ -105,6 +105,14 @@ def test_ws_rejects_missing_or_wrong_token(client: TestClient) -> None: ws.receive_json() +def test_unknown_route_is_a_clean_404(client: TestClient) -> None: + res = client.get("/api/learning/graph") + assert res.status_code == 404 + assert res.json() == {"error": "not found"} + res = client.post("/api/nothing/here") + assert res.status_code == 404 + + def test_announce_ready_prints_marker_and_writes_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture ) -> None: diff --git a/tests/server/test_desktop_sessions.py b/tests/server/test_desktop_sessions.py new file mode 100644 index 00000000..c7fd368a --- /dev/null +++ b/tests/server/test_desktop_sessions.py @@ -0,0 +1,253 @@ +"""Tests for the sessions REST surface, resume hydration, and the desktop +launcher plan (stage 3 of the desktop port).""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from starlette.testclient import TestClient + +from src.server.desktop_serve import DesktopServeState, build_app + +TOKEN = "sess-test-token" +AUTH = {"X-ClawCodex-Session-Token": TOKEN} + + +def _write_session(dir_: Path, session_id: str, *, preview: str, count: int, + messages: list | None = None, age_s: float = 0.0) -> None: + data = { + "session_id": session_id, + "updated_at": "2026-08-08T00:00:00Z", + "preview": preview, + "name": None, + "message_count": count, + "model": "m1", + "provider": "p1", + "cwd": "/tmp/w", + "mode": "default", + "turns": 1, + "conversation": {"max_history": 100, "messages": messages or []}, + } + path = dir_ / f"{session_id}.json" + path.write_text(json.dumps(data), encoding="utf-8") + if age_s: + stamp = time.time() - age_s + os.utime(path, (stamp, stamp)) + + +def _state(tmp_path: Path) -> DesktopServeState: + async def _spawn(*a, **kw): # pragma: no cover + raise AssertionError("no spawns in REST tests") + + return DesktopServeState( + token=TOKEN, + workspace=str(tmp_path), + manager=None, + spawn_agent=_spawn, + protocol_version="0.1.0", + sessions_dir=tmp_path / "sessions", + ) + + +@pytest.fixture() +def rest(tmp_path: Path) -> TestClient: + (tmp_path / "sessions").mkdir() + return TestClient(build_app(_state(tmp_path))) + + +def test_sessions_requires_token(rest: TestClient) -> None: + assert rest.get("/api/sessions").status_code == 401 + + +def test_sessions_lists_newest_first_with_pagination( + rest: TestClient, tmp_path: Path +) -> None: + d = tmp_path / "sessions" + _write_session(d, "old-one", preview="first chat", count=4, age_s=300) + _write_session(d, "mid-one", preview="second chat", count=6, age_s=100) + _write_session(d, "new-one", preview="third chat", count=2, age_s=1) + + body = rest.get("/api/sessions", headers=AUTH).json() + assert [r["id"] for r in body["sessions"]] == ["new-one", "mid-one", "old-one"] + assert body["total"] == 3 + row = body["sessions"][0] + assert row["preview"] == "third chat" + assert row["title"] == "third chat" + assert row["message_count"] == 2 + assert row["model"] == "m1" + assert row["cwd"] == "/tmp/w" + + page = rest.get("/api/sessions?limit=1&offset=1", headers=AUTH).json() + assert [r["id"] for r in page["sessions"]] == ["mid-one"] + assert page["total"] == 3 + + filtered = rest.get("/api/sessions?min_messages=5", headers=AUTH).json() + assert [r["id"] for r in filtered["sessions"]] == ["mid-one"] + + +def test_sessions_skips_corrupt_files(rest: TestClient, tmp_path: Path) -> None: + d = tmp_path / "sessions" + _write_session(d, "good-one", preview="ok", count=1) + (d / "broken.json").write_text("{not json", encoding="utf-8") + + body = rest.get("/api/sessions", headers=AUTH).json() + assert [r["id"] for r in body["sessions"]] == ["good-one"] + + +def test_session_messages_hydrates_and_hides_reminders( + rest: TestClient, tmp_path: Path +) -> None: + d = tmp_path / "sessions" + _write_session( + d, "chat-a", preview="hello?", count=3, + messages=[ + {"role": "user", "content": "hello?"}, + {"role": "assistant", "content": [{"type": "text", "text": "hi back"}]}, + {"role": "user", "content": "\nnoise\n"}, + ], + ) + body = rest.get("/api/sessions/chat-a/messages", headers=AUTH).json() + assert body["session_id"] == "chat-a" + assert body["message_count"] == 3 + roles = [m["role"] for m in body["messages"]] + assert roles == ["user", "assistant", "user"] + assert body["messages"][1]["content"] == [{"type": "text", "text": "hi back"}] + assert body["messages"][2]["display_kind"] == "hidden" + assert "display_kind" not in body["messages"][0] + + +def test_session_messages_404_and_traversal_refused( + rest: TestClient, tmp_path: Path +) -> None: + assert rest.get("/api/sessions/nope/messages", headers=AUTH).status_code == 404 + res = rest.get("/api/sessions/..%2F..%2Fetc/messages", headers=AUTH) + assert res.status_code == 404 + + +def test_model_info_reports_defaults( + rest: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("src.config.get_default_provider", lambda: "anthropic") + monkeypatch.setattr( + "src.config.get_provider_config", + lambda n: {"default_model": "claude-fable-5"}, + ) + body = rest.get("/api/model/info", headers=AUTH).json() + assert body == {"provider": "anthropic", "model": "claude-fable-5"} + + +def test_profile_sessions_slice_filters_sources( + rest: TestClient, tmp_path: Path +) -> None: + d = tmp_path / "sessions" + _write_session(d, "chat-b", preview="normal", count=3, age_s=10) + data = json.loads((d / "chat-b.json").read_text()) + data["mode"] = "cron" + data["session_id"] = "cron-b" + (d / "cron-b.json").write_text(json.dumps(data), encoding="utf-8") + + all_rows = rest.get("/api/profiles/sessions?limit=10", headers=AUTH).json() + assert {r["id"] for r in all_rows["sessions"]} == {"chat-b", "cron-b"} + assert all(r["profile"] == "default" for r in all_rows["sessions"]) + + cron_only = rest.get( + "/api/profiles/sessions?source=cron", headers=AUTH + ).json() + assert [r["id"] for r in cron_only["sessions"]] == ["cron-b"] + + excluded = rest.get( + "/api/profiles/sessions?exclude_sources=cron,web", headers=AUTH + ).json() + assert [r["id"] for r in excluded["sessions"]] == ["chat-b"] + + +def test_sidebar_batches_slices(rest: TestClient, tmp_path: Path) -> None: + d = tmp_path / "sessions" + _write_session(d, "chat-c", preview="hey", count=2) + body = rest.get( + "/api/profiles/sessions/sidebar?recents_profile=default&recents_limit=5" + "&cron_limit=3&messaging_limit=3", + headers=AUTH, + ).json() + assert [r["id"] for r in body["recents"]["sessions"]] == ["chat-c"] + assert body["cron"]["sessions"] == [] + assert body["messaging"]["sessions"] == [] + + +def test_profiles_and_active_and_defaults( + rest: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("src.config.get_default_provider", lambda: "anthropic") + monkeypatch.setattr( + "src.config.get_provider_config", lambda n: {"default_model": "m"} + ) + monkeypatch.setattr( + "src.config.load_config", lambda: {"env": {"K": "v"}} + ) + body = rest.get("/api/profiles", headers=AUTH).json() + assert len(body["profiles"]) == 1 + profile = body["profiles"][0] + assert profile["name"] == "default" + assert profile["is_default"] is True + assert profile["provider"] == "anthropic" + assert profile["has_env"] is True + + active = rest.get("/api/profiles/active", headers=AUTH).json() + assert active == {"active": "default", "current": "default"} + + monkeypatch.setattr( + "src.config.get_default_config", + lambda: {"display": {"skin": "x"}, "env": {"S": "hide"}}, + ) + defaults = rest.get("/api/config/defaults", headers=AUTH).json() + assert defaults == {"display": {"skin": "x"}} + + +# ─── desktop launcher plan ─────────────────────────────────────────────────── + + +def test_desktop_launch_plan_installs_when_missing(tmp_path: Path) -> None: + from src.entrypoints.desktop_cli import build_launch_plan + + plan = build_launch_plan(tmp_path, install=False, dev=True) + assert plan == [["npm", "ci"], ["npm", "run", "dev"]] + + (tmp_path / "node_modules").mkdir() + assert build_launch_plan(tmp_path, install=False, dev=True) == [["npm", "run", "dev"]] + assert build_launch_plan(tmp_path, install=True, dev=False) == [ + ["npm", "ci"], + ["npm", "run", "start"], + ] + + +def test_desktop_launch_env_pins_backend_root(tmp_path: Path, + monkeypatch: pytest.MonkeyPatch) -> None: + from src.entrypoints.desktop_cli import launch_env + + monkeypatch.delenv("CLAWCODEX_DESKTOP_CLAWCODEX_ROOT", raising=False) + env = launch_env(tmp_path) + assert env["CLAWCODEX_DESKTOP_CLAWCODEX_ROOT"] == str(tmp_path) + + monkeypatch.setenv("CLAWCODEX_DESKTOP_CLAWCODEX_ROOT", "/explicit") + assert launch_env(tmp_path)["CLAWCODEX_DESKTOP_CLAWCODEX_ROOT"] == "/explicit" + + +def test_desktop_dir_resolves_inside_repo() -> None: + from src.entrypoints.desktop_cli import desktop_dir, repo_root + + root = repo_root() + assert (root / "src" / "cli.py").is_file() + assert desktop_dir(root) == root / "ui-desktop" + + +def test_desktop_subcommand_refuses_without_app(tmp_path: Path, + monkeypatch: pytest.MonkeyPatch) -> None: + import src.entrypoints.desktop_cli as mod + + monkeypatch.setattr(mod, "repo_root", lambda: tmp_path) + assert mod.run_desktop_subcommand([]) == 2 diff --git a/ui-desktop/src/store/updates.ts b/ui-desktop/src/store/updates.ts index 16df2d6d..b78dbe2a 100644 --- a/ui-desktop/src/store/updates.ts +++ b/ui-desktop/src/store/updates.ts @@ -90,11 +90,13 @@ function isUpdateToastSnoozed(): boolean { // Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written // against. The backend reports its own value in session runtime info; a lower // value (or none — a pre-GUI checkout) means GUI<->backend skew. -// v2: requires the file.attach RPC (remote-gateway non-image file upload). -// v3: requires approvals.mode config RPCs and session.info reconciliation. -// v4: requires explicit Fast-off session creation and session-scoped Fast edits. -// v5: requires raised WebSocket frame size for large one-shot file.attach. -const REQUIRED_BACKEND_CONTRACT = 5 +// The ClawCodex contract ladder restarts at v1 (the reference implementation +// retired at v5 — its v2-v5 capabilities land stage by stage and will bump +// this again): +// v1: serve core — /api/ws JSON-RPC gateway, sessions REST, resume control. +// The backend reports its value in session runtime info +// (src/server/desktop_gateway_methods.py DESKTOP_CONTRACT — keep in sync). +const REQUIRED_BACKEND_CONTRACT = 1 const SKEW_TOAST_ID = 'backend-contract-skew' // The contract check runs on every session.resume (applyRuntimeInfo), so // without a snooze the warning re-popped on every thread the user opened, even