diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml
new file mode 100644
index 000000000..39972626f
--- /dev/null
+++ b/.github/workflows/backend-tests.yml
@@ -0,0 +1,71 @@
+name: backend-tests
+
+# The backend pytest suite, run on every pull request and on pushes to the mainline branches. Until
+# now nothing ran it in CI, so a regression only surfaced when someone ran it by hand.
+on:
+ pull_request:
+ paths:
+ - 'backend/**'
+ - '.github/workflows/backend-tests.yml'
+ push:
+ branches: [main, dev]
+ paths:
+ - 'backend/**'
+ - '.github/workflows/backend-tests.yml'
+ workflow_dispatch:
+
+# Runs checked-out project code on pull_request: the token stays read-only.
+permissions:
+ contents: read
+
+concurrency:
+ group: backend-tests-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ pytest:
+ name: pytest (ubuntu)
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ cache: pip
+ cache-dependency-path: |
+ backend/requirements.lock
+ backend/requirements-dev.txt
+ - name: Install backend deps (locked runtime + dev)
+ run: |
+ python -m pip install --require-hashes --only-binary=:all: -r backend/requirements.lock
+ python -m pip install -r backend/requirements-dev.txt
+ # Two numbers that must agree. A test process that dies mid-run can exit 0 with no summary
+ # (a hard-exit shutdown path did exactly that once and silently skipped ~42% of the suite), so
+ # the job also asserts that every collected test was actually run: junit testcase count ==
+ # collect-only count. Green then means green, not "green as far as it got".
+ - name: Count the suite
+ run: |
+ python -m pytest backend/tests --ignore=backend/tests/formal --collect-only -q -p no:cacheprovider \
+ | tail -1 | tee collected.txt
+ # --timeout: a test that blocks forever (a bare ws.receive_json() waiting for an event that never
+ # comes, say) otherwise stalls the run at 99% until timeout-minutes with no summary and no junit.
+ # With the cap it fails by name, the rest of the suite runs, and the assertion below still holds.
+ - name: Run the backend suite
+ run: |
+ python -m pytest backend/tests --ignore=backend/tests/formal -q -p no:cacheprovider \
+ --timeout=300 \
+ --junitxml "${RUNNER_TEMP}/pytest.xml"
+ - name: Every collected test ran
+ # Runs after a red suite too, so a failure report also says whether the run was complete.
+ if: ${{ !cancelled() }}
+ run: |
+ python - "${RUNNER_TEMP}/pytest.xml" collected.txt <<'PY'
+ import re, sys, xml.etree.ElementTree as ET
+ ran = sum(1 for _ in ET.parse(sys.argv[1]).getroot().iter('testcase'))
+ m = re.search(r'(\d+) tests? collected', open(sys.argv[2]).read())
+ collected = int(m.group(1)) if m else -1
+ print(f'collected={collected} ran={ran}')
+ if collected < 1 or ran != collected:
+ sys.exit(f'FAIL: {ran} of {collected} collected tests reached the report; the run was truncated')
+ PY
diff --git a/.github/workflows/edge-tests.yml b/.github/workflows/edge-tests.yml
new file mode 100644
index 000000000..82ef10d9d
--- /dev/null
+++ b/.github/workflows/edge-tests.yml
@@ -0,0 +1,44 @@
+name: edge-tests
+
+# The openswarm-edge pytest suite, on every pull request that touches it and on pushes to the
+# mainline branches.
+on:
+ pull_request:
+ paths:
+ - 'openswarm-edge/**'
+ - '.github/workflows/edge-tests.yml'
+ push:
+ branches: [main, dev]
+ paths:
+ - 'openswarm-edge/**'
+ - '.github/workflows/edge-tests.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: edge-tests-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ pytest:
+ name: pytest (openswarm-edge)
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: openswarm-edge
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ cache: pip
+ cache-dependency-path: openswarm-edge/requirements.txt
+ - name: Install edge deps
+ run: |
+ python -m pip install -r requirements.txt
+ python -m pip install pytest pytest-asyncio
+ - name: Run the edge suite
+ run: python -m pytest tests -q -p no:cacheprovider
diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml
new file mode 100644
index 000000000..00578bb8f
--- /dev/null
+++ b/.github/workflows/frontend-tests.yml
@@ -0,0 +1,43 @@
+name: frontend-tests
+
+# Typecheck plus the renderer's node:test suite, on every pull request and on pushes to the mainline
+# branches. Until now neither ran in CI; the tests were run by hand, one file at a time.
+on:
+ pull_request:
+ paths:
+ - 'frontend/**'
+ - '.github/workflows/frontend-tests.yml'
+ push:
+ branches: [main, dev]
+ paths:
+ - 'frontend/**'
+ - '.github/workflows/frontend-tests.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: frontend-tests-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ typecheck-and-tests:
+ name: tsc + node:test
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.18.1'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+ - run: npm ci
+ - name: Typecheck
+ run: npx tsc --noEmit -p tsconfig.json
+ - name: Unit tests (node:test via tsx)
+ run: node scripts/run-tests.mjs
diff --git a/backend/apps/dashboards/dashboard_runtime.py b/backend/apps/dashboards/dashboard_runtime.py
new file mode 100644
index 000000000..2b3781701
--- /dev/null
+++ b/backend/apps/dashboards/dashboard_runtime.py
@@ -0,0 +1,105 @@
+"""Injected cross-app runtime boundary for the dashboards app."""
+
+from __future__ import annotations
+
+from typing import Any, Optional, Protocol, runtime_checkable
+
+from backend.apps.agents import agent_manager as agents_runtime
+from backend.apps.agents.core import aux_llm
+from backend.apps.agents.manager.session import session_store
+from backend.apps.agents.providers import registry
+from backend.apps.service.analytics import client as analytics
+from backend.apps.settings import credentials
+from backend.apps.settings import settings as settings_app
+
+
+@runtime_checkable
+class DashboardTelemetry(Protocol):
+ """Fire-and-forget analytics the dashboard routes emit."""
+
+ def dashboard_event(self, *, dashboard_id: str, action: str) -> None: ...
+
+
+@runtime_checkable
+class SessionAuthority(Protocol):
+ """Live/persisted agent-session operations the dashboard routes need."""
+
+ def live_sessions(self) -> dict[str, Any]: ...
+
+ def load_session_data(self, session_id: str) -> Optional[dict]: ...
+
+ def save_session(self, session_id: str, data: dict) -> None: ...
+
+ async def delete_session(self, session_id: str) -> None: ...
+
+ async def duplicate_session(self, session_id: str, *, dashboard_id: str) -> Any: ...
+
+ def purge_session_memory(self, session_id: str) -> None: ...
+
+
+@runtime_checkable
+class AuxNaming(Protocol):
+ """Primitive lookups behind auto-naming; prompt/stream logic stays in dashboards.py."""
+
+ def load_settings(self) -> Any: ...
+
+ async def resolve_aux_model(self, settings: Any, *, preferred_tier: str) -> tuple[str, Any]: ...
+
+ def client_for_model(self, settings: Any, model: str) -> Any: ...
+
+ def clean_short_label(self, text: str) -> str: ...
+
+ def aux_max_tokens_for(self, model: str) -> int: ...
+
+
+class DefaultDashboardTelemetry:
+ """Production adapter; dynamic lookups preserve established test seams."""
+
+ def dashboard_event(self, *, dashboard_id: str, action: str) -> None:
+ analytics.track_dashboard_event(dashboard_id=dashboard_id, action=action)
+
+
+class DefaultSessionAuthority:
+ """Production adapter; dynamic lookups preserve established test seams."""
+
+ def live_sessions(self) -> dict[str, Any]:
+ return agents_runtime.agent_manager.sessions
+
+ def load_session_data(self, session_id: str) -> Optional[dict]:
+ return session_store.load_session_data(session_id)
+
+ def save_session(self, session_id: str, data: dict) -> None:
+ session_store.save_session(session_id, data)
+
+ async def delete_session(self, session_id: str) -> None:
+ await agents_runtime.agent_manager.delete_session(session_id)
+
+ async def duplicate_session(self, session_id: str, *, dashboard_id: str) -> Any:
+ return await agents_runtime.agent_manager.duplicate_session(session_id, dashboard_id=dashboard_id)
+
+ def purge_session_memory(self, session_id: str) -> None:
+ agents_runtime.agent_manager.purge_session_memory(session_id)
+
+
+class DefaultAuxNaming:
+ """Production adapter; dynamic lookups preserve established test seams."""
+
+ def load_settings(self) -> Any:
+ return settings_app.load_settings()
+
+ async def resolve_aux_model(self, settings: Any, *, preferred_tier: str) -> tuple[str, Any]:
+ return await registry.resolve_aux_model(settings, preferred_tier=preferred_tier)
+
+ def client_for_model(self, settings: Any, model: str) -> Any:
+ return credentials.get_anthropic_client_for_model(settings, model)
+
+ def clean_short_label(self, text: str) -> str:
+ return aux_llm.clean_short_label(text)
+
+ def aux_max_tokens_for(self, model: str) -> int:
+ return aux_llm.aux_max_tokens_for(model)
+
+
+DEFAULT_DASHBOARD_TELEMETRY: DashboardTelemetry = DefaultDashboardTelemetry()
+DEFAULT_SESSION_AUTHORITY: SessionAuthority = DefaultSessionAuthority()
+DEFAULT_AUX_NAMING: AuxNaming = DefaultAuxNaming()
diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py
index 13d3b3e68..1fad3a950 100644
--- a/backend/apps/dashboards/dashboards.py
+++ b/backend/apps/dashboards/dashboards.py
@@ -1,11 +1,13 @@
import json
import os
import logging
+import threading
from contextlib import asynccontextmanager
from datetime import datetime
from uuid import uuid4
from backend.config.Apps import SubApp
+from backend.apps.dashboards import dashboard_runtime
from backend.apps.dashboards.models import (
Dashboard,
DashboardCreate,
@@ -23,6 +25,25 @@
from backend.config.json_store import read_json_or_none, atomic_write_json
OLD_LAYOUT_FILE = os.path.join(OLD_LAYOUT_DIR, "layout.json")
+# One lock around every read-modify-write of a dashboard file: layout saves, renames, thumbnail writes, delete and duplicate arrive concurrently from the renderer, and two writers interleaving on the same file lost one of them.
+p_dashboard_lifecycle_lock = threading.RLock()
+
+
+async def p_rollback_duplicated_sessions(
+ authority: dashboard_runtime.SessionAuthority, duplicated_sessions
+) -> None:
+ for _, session in reversed(duplicated_sessions):
+ try:
+ await authority.delete_session(session.id)
+ except Exception:
+ logger.exception("Failed to roll back duplicated session %s", session.id)
+ try:
+ authority.purge_session_memory(session.id)
+ session_path = os.path.join(SESSIONS_DIR, f"{session.id}.json")
+ if os.path.exists(session_path):
+ os.remove(session_path)
+ except Exception:
+ logger.exception("Fallback rollback failed for duplicated session %s", session.id)
def load_all() -> list[Dashboard]:
@@ -43,21 +64,24 @@ def load_all() -> list[Dashboard]:
def save(dashboard: Dashboard):
- atomic_write_json(os.path.join(DATA_DIR, f"{dashboard.id}.json"), dashboard.model_dump(mode="json"))
+ with p_dashboard_lifecycle_lock:
+ atomic_write_json(os.path.join(DATA_DIR, f"{dashboard.id}.json"), dashboard.model_dump(mode="json"))
def load(dashboard_id: str) -> Dashboard:
- path = os.path.join(DATA_DIR, f"{dashboard_id}.json")
- data = read_json_or_none(path)
- if data is None:
- raise HTTPException(status_code=404, detail="Dashboard not found")
- return Dashboard(**data)
+ with p_dashboard_lifecycle_lock:
+ path = os.path.join(DATA_DIR, f"{dashboard_id}.json")
+ data = read_json_or_none(path)
+ if data is None:
+ raise HTTPException(status_code=404, detail="Dashboard not found")
+ return Dashboard(**data)
def p_delete(dashboard_id: str):
- path = os.path.join(DATA_DIR, f"{dashboard_id}.json")
- if os.path.exists(path):
- os.remove(path)
+ with p_dashboard_lifecycle_lock:
+ path = os.path.join(DATA_DIR, f"{dashboard_id}.json")
+ if os.path.exists(path):
+ os.remove(path)
def migrate_if_needed():
@@ -135,8 +159,7 @@ async def create_dashboard(body: DashboardCreate):
dashboard = Dashboard(name=body.name)
save(dashboard)
try:
- from backend.apps.service.analytics.client import track_dashboard_event
- track_dashboard_event(dashboard_id=dashboard.id, action="create")
+ dashboard_runtime.DEFAULT_DASHBOARD_TELEMETRY.dashboard_event(dashboard_id=dashboard.id, action="create")
except Exception:
pass
return dashboard.model_dump(mode="json")
@@ -306,10 +329,8 @@ async def generate_name(dashboard_id: str):
if not dashboard.auto_named and dashboard.name != "Untitled Dashboard":
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
- from backend.apps.agents.agent_manager import agent_manager
-
prompts = []
- for session in agent_manager.sessions.values():
+ for session in dashboard_runtime.DEFAULT_SESSION_AUTHORITY.live_sessions().values():
if getattr(session, "dashboard_id", None) != dashboard_id:
continue
for msg in session.messages:
@@ -321,13 +342,11 @@ async def generate_name(dashboard_id: str):
return {"name": dashboard.name, "auto_named": dashboard.auto_named}
fallback = " ".join(prompts[0].split()[:4])[:36] or "Untitled Dashboard"
+ naming = dashboard_runtime.DEFAULT_AUX_NAMING
try:
- from backend.apps.settings.settings import load_settings
- from backend.apps.settings.credentials import get_anthropic_client_for_model
- from backend.apps.agents.providers.registry import resolve_aux_model
- global_settings = load_settings()
- aux_model, p_aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku")
- client = get_anthropic_client_for_model(global_settings, aux_model)
+ global_settings = naming.load_settings()
+ aux_model, p_aux_base = await naming.resolve_aux_model(global_settings, preferred_tier="haiku")
+ client = naming.client_for_model(global_settings, aux_model)
# Mirrors generate_title's hardening: the tasks are inert text to LABEL, never answer, or the aux model happily replies with a markdown essay that becomes the title.
system = (
@@ -342,17 +361,16 @@ async def generate_name(dashboard_id: str):
"\n" + "\n".join(f"- {p}" for p in prompts) + "\n"
)
- from backend.apps.agents.core.aux_llm import clean_short_label, aux_max_tokens_for
chunks: list[str] = []
async with client.messages.stream(
model=aux_model,
- max_tokens=aux_max_tokens_for(aux_model),
+ max_tokens=naming.aux_max_tokens_for(aux_model),
system=system,
messages=[{"role": "user", "content": user_content}],
) as stream:
async for text in stream.text_stream:
chunks.append(text)
- generated = clean_short_label("".join(chunks))
+ generated = naming.clean_short_label("".join(chunks))
if generated:
fallback = generated
except Exception as e:
@@ -365,7 +383,9 @@ async def generate_name(dashboard_id: str):
return {"name": dashboard.name, "auto_named": True}
-def strip_orphan_session_cards(data: dict) -> None:
+def strip_orphan_session_cards(
+ data: dict, authority: dashboard_runtime.SessionAuthority | None = None
+) -> None:
"""Drop layout cards (and expanded ids) whose agent session no longer exists
anywhere, in memory OR on disk. The frontend mounts an AgentChat per card and
GETs its session; a card pointing at a vanished session (e.g. an empty
@@ -375,8 +395,7 @@ def strip_orphan_session_cards(data: dict) -> None:
cards and nothing else. Filtering the RESPONSE (never the stored file) is
non-destructive: a wrong check can only hide a card for one response, not
delete it. Drafts have no backend session yet, so they're always kept."""
- from backend.apps.agents.agent_manager import agent_manager
- from backend.apps.agents.manager.session.session_store import load_session_data
+ live = authority or dashboard_runtime.DEFAULT_SESSION_AUTHORITY
layout = data.get("layout")
if not isinstance(layout, dict):
return
@@ -385,9 +404,9 @@ def strip_orphan_session_cards(data: dict) -> None:
return
def gone(sid: str) -> bool:
- if sid.startswith("draft-") or sid in agent_manager.sessions:
+ if sid.startswith("draft-") or sid in live.live_sessions():
return False
- return load_session_data(sid) is None
+ return live.load_session_data(sid) is None
orphans = [sid for sid in cards if gone(sid)]
for sid in orphans:
@@ -408,26 +427,29 @@ async def get_dashboard(dashboard_id: str):
@dashboards.router.put("/{dashboard_id}")
async def update_dashboard(dashboard_id: str, body: DashboardUpdate):
- dashboard = load(dashboard_id)
- if body.name is not None:
- dashboard.name = body.name
- dashboard.auto_named = False
- if body.layout is not None:
- dashboard.layout = body.layout
- now = datetime.now()
- if body.thumbnail is not None:
- dashboard.thumbnail = body.thumbnail
- dashboard.preview_signature = body.preview_signature
- # Only a real screenshot write moves the sort key; layout/rename saves don't reorder.
- dashboard.preview_updated_at = now
- dashboard.updated_at = now
- save(dashboard)
+ with p_dashboard_lifecycle_lock:
+ dashboard = load(dashboard_id)
+ if body.name is not None:
+ dashboard.name = body.name
+ dashboard.auto_named = False
+ if body.layout is not None:
+ dashboard.layout = body.layout
+ now = datetime.now()
+ if body.thumbnail is not None:
+ dashboard.thumbnail = body.thumbnail
+ dashboard.preview_signature = body.preview_signature
+ # Only a real screenshot write moves the sort key; layout/rename saves don't reorder.
+ dashboard.preview_updated_at = now
+ dashboard.updated_at = now
+ save(dashboard)
return dashboard.model_dump(mode="json")
@dashboards.router.delete("/{dashboard_id}")
async def delete_dashboard(dashboard_id: str):
- load(dashboard_id)
+ with p_dashboard_lifecycle_lock:
+ load(dashboard_id)
+ p_delete(dashboard_id)
if os.path.exists(SESSIONS_DIR):
for fname in os.listdir(SESSIONS_DIR):
@@ -442,21 +464,20 @@ async def delete_dashboard(dashboard_id: str):
except Exception:
logger.warning(f"Failed to read/delete session file {fname}")
- from backend.apps.agents.agent_manager import agent_manager
+ authority = dashboard_runtime.DEFAULT_SESSION_AUTHORITY
to_remove = [
- sid for sid, sess in agent_manager.sessions.items()
+ sid for sid, sess in authority.live_sessions().items()
if getattr(sess, "dashboard_id", None) == dashboard_id
]
for sid in to_remove:
try:
- await agent_manager.delete_session(sid)
+ await authority.delete_session(sid)
except Exception:
logger.warning(f"Failed to delete active session {sid} during dashboard deletion")
p_delete(dashboard_id)
try:
- from backend.apps.service.analytics.client import track_dashboard_event
- track_dashboard_event(dashboard_id=dashboard_id, action="delete")
+ dashboard_runtime.DEFAULT_DASHBOARD_TELEMETRY.dashboard_event(dashboard_id=dashboard_id, action="delete")
except Exception:
pass
return {"ok": True}
@@ -469,8 +490,7 @@ async def duplicate_dashboard(dashboard_id: str):
new_id = uuid4().hex
now = datetime.now().isoformat()
- from backend.apps.agents.agent_manager import agent_manager
- from backend.apps.agents.manager.session.session_store import save_session
+ authority = dashboard_runtime.DEFAULT_SESSION_AUTHORITY
source_layout = source_data.get("layout", {}) or {}
source_browser_cards = source_layout.get("browser_cards", {}) or {}
@@ -484,7 +504,7 @@ async def duplicate_dashboard(dashboard_id: str):
new_browser_cards[new_bid] = new_card
candidate_ids: set[str] = set()
- for sid, sess in agent_manager.sessions.items():
+ for sid, sess in authority.live_sessions().items():
if getattr(sess, "dashboard_id", None) == dashboard_id:
candidate_ids.add(sid)
if os.path.exists(SESSIONS_DIR):
@@ -499,7 +519,7 @@ async def duplicate_dashboard(dashboard_id: str):
duplicated_sessions = [] # (old_id, new_session)
for old_sid in candidate_ids:
try:
- new_sess = await agent_manager.duplicate_session(old_sid, dashboard_id=new_id)
+ new_sess = await authority.duplicate_session(old_sid, dashboard_id=new_id)
except Exception:
logger.warning(f"Failed to duplicate session {old_sid} during dashboard duplication", exc_info=True)
continue
@@ -507,7 +527,7 @@ async def duplicate_dashboard(dashboard_id: str):
duplicated_sessions.append((old_sid, new_sess))
for old_sid, new_sess in duplicated_sessions:
- source_sess = agent_manager.sessions.get(old_sid)
+ source_sess = authority.live_sessions().get(old_sid)
old_browser_id = getattr(source_sess, "browser_id", None) if source_sess else None
old_parent_sid = getattr(source_sess, "parent_session_id", None) if source_sess else None
if old_browser_id is None or old_parent_sid is None:
@@ -520,7 +540,7 @@ async def duplicate_dashboard(dashboard_id: str):
new_sess.browser_id = browser_id_remap[old_browser_id]
if old_parent_sid and old_parent_sid in session_id_remap:
new_sess.parent_session_id = session_id_remap[old_parent_sid]
- save_session(new_sess.id, new_sess.model_dump(mode="json"))
+ authority.save_session(new_sess.id, new_sess.model_dump(mode="json"))
new_cards: dict[str, dict] = {}
for old_sid, card in source_cards.items():
@@ -558,11 +578,16 @@ async def duplicate_dashboard(dashboard_id: str):
"updated_at": now,
"layout": new_layout,
}
- atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard)
+ try:
+ with p_dashboard_lifecycle_lock:
+ load(dashboard_id)
+ atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard)
+ except Exception:
+ await p_rollback_duplicated_sessions(authority, duplicated_sessions)
+ raise
try:
- from backend.apps.service.analytics.client import track_dashboard_event
- track_dashboard_event(dashboard_id=new_id, action="create")
+ dashboard_runtime.DEFAULT_DASHBOARD_TELEMETRY.dashboard_event(dashboard_id=new_id, action="create")
except Exception:
pass
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
index fe1535065..710b74dc3 100644
--- a/backend/requirements-dev.txt
+++ b/backend/requirements-dev.txt
@@ -9,6 +9,9 @@
pytest==8.3.4
pytest-asyncio==0.25.2
+# Per-test wall-clock cap for CI (see .github/workflows/backend-tests.yml): a test that
+# blocks forever fails by name instead of stalling the whole run until the job cap.
+pytest-timeout==2.4.0
# Used by linter/lint.py (the vulture dead-code check). watchfiles, also
# needed by lint.py, already comes in transitively via uvicorn[standard].
diff --git a/backend/tests/test_dashboard_runtime_port.py b/backend/tests/test_dashboard_runtime_port.py
new file mode 100644
index 000000000..a55b8a3c7
--- /dev/null
+++ b/backend/tests/test_dashboard_runtime_port.py
@@ -0,0 +1,278 @@
+"""Contract tests for the dashboards app's injected runtime boundary.
+
+Proves the injected runtime boundary through the public surface only: the
+dashboard routes and the strip_orphan_session_cards helper consult the
+injected SessionAuthority/DashboardTelemetry/AuxNaming ports, injected
+fakes fully control behavior while the underlying sibling apps are patched
+to reject any access, and the lazy sibling imports cannot silently return.
+
+Run:
+ python -m pytest backend/tests/test_dashboard_runtime_port.py -v
+"""
+
+from __future__ import annotations
+
+import ast
+import asyncio
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from httpx import ASGITransport, AsyncClient
+
+import backend.apps.agents.agent_manager as agent_manager_module
+import backend.apps.agents.manager.session.session_store as session_store_module
+import backend.apps.service.analytics.client as analytics_module
+from backend.apps.dashboards import dashboard_runtime
+from backend.apps.dashboards import dashboards as dashboard_routes
+from backend.apps.dashboards.dashboard_runtime import (
+ AuxNaming,
+ DashboardTelemetry,
+ DefaultAuxNaming,
+ DefaultDashboardTelemetry,
+ DefaultSessionAuthority,
+ SessionAuthority,
+)
+from backend.apps.dashboards.models import Dashboard
+
+
+class FakeSession:
+ def __init__(self, id, dashboard_id):
+ self.id = id
+ self.dashboard_id = dashboard_id
+ self.messages = []
+ self.browser_id = None
+ self.parent_session_id = None
+
+ def model_dump(self, mode="json"):
+ return {"id": self.id, "dashboard_id": self.dashboard_id}
+
+
+class FakeAuthority:
+ def __init__(self, sessions=None, disk=None):
+ self.sessions = dict(sessions or {})
+ self.disk = dict(disk or {})
+ self.deleted = []
+ self.saved = []
+ self.purged = []
+
+ def live_sessions(self):
+ return self.sessions
+
+ def load_session_data(self, session_id):
+ return self.disk.get(session_id)
+
+ def save_session(self, session_id, data):
+ self.saved.append((session_id, data))
+
+ async def delete_session(self, session_id):
+ self.deleted.append(session_id)
+ self.sessions.pop(session_id, None)
+
+ async def duplicate_session(self, session_id, *, dashboard_id):
+ return FakeSession(f"dup-{session_id}", dashboard_id)
+
+ def purge_session_memory(self, session_id):
+ self.purged.append(session_id)
+
+
+class FakeTelemetry:
+ def __init__(self):
+ self.events = []
+
+ def dashboard_event(self, *, dashboard_id, action):
+ self.events.append((action, dashboard_id))
+
+
+class FakeNaming:
+ """Naming port whose settings load always fails, forcing the fallback path."""
+
+ def load_settings(self):
+ raise RuntimeError("settings off limits")
+
+ async def resolve_aux_model(self, settings, *, preferred_tier):
+ raise AssertionError("must not be reached after load_settings fails")
+
+ def client_for_model(self, settings, model):
+ raise AssertionError("must not be reached")
+
+ def clean_short_label(self, text):
+ return text
+
+ def aux_max_tokens_for(self, model):
+ return 16
+
+
+@pytest.fixture
+def env(tmp_path, monkeypatch):
+ data_dir = tmp_path / "dashboards"
+ sessions_dir = tmp_path / "sessions"
+ data_dir.mkdir()
+ sessions_dir.mkdir()
+ monkeypatch.setattr(dashboard_routes, "DATA_DIR", str(data_dir))
+ monkeypatch.setattr(dashboard_routes, "SESSIONS_DIR", str(sessions_dir))
+
+ def deny(*args, **kwargs):
+ raise AssertionError("sibling app must not be touched when ports are injected")
+ monkeypatch.setattr(agent_manager_module, "agent_manager", None)
+ monkeypatch.setattr(analytics_module, "track_dashboard_event", deny)
+ monkeypatch.setattr(session_store_module, "load_session_data", deny)
+ monkeypatch.setattr(session_store_module, "save_session", deny)
+
+ authority = FakeAuthority()
+ telemetry = FakeTelemetry()
+ monkeypatch.setattr(dashboard_runtime, "DEFAULT_SESSION_AUTHORITY", authority)
+ monkeypatch.setattr(dashboard_runtime, "DEFAULT_DASHBOARD_TELEMETRY", telemetry)
+ monkeypatch.setattr(dashboard_runtime, "DEFAULT_AUX_NAMING", FakeNaming())
+
+ app = FastAPI()
+ app.include_router(dashboard_routes.dashboards.router, prefix="/api/dashboards")
+ return SimpleNamespace(app=app, data_dir=data_dir, authority=authority, telemetry=telemetry)
+
+
+def request(app, method, path, json_payload=None):
+ async def go():
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ return await client.request(method, path, json=json_payload)
+ return asyncio.run(go())
+
+
+# --- port conformance ---------------------------------------------------------
+
+def test_default_adapters_conform_to_protocols():
+ assert isinstance(DefaultSessionAuthority(), SessionAuthority)
+ assert isinstance(DefaultDashboardTelemetry(), DashboardTelemetry)
+ assert isinstance(DefaultAuxNaming(), AuxNaming)
+
+
+def test_fakes_conform_to_protocols():
+ assert isinstance(FakeAuthority(), SessionAuthority)
+ assert isinstance(FakeTelemetry(), DashboardTelemetry)
+ assert isinstance(FakeNaming(), AuxNaming)
+
+
+# --- routes consume only the injected ports -----------------------------------
+
+def test_create_reports_through_injected_telemetry(env):
+ response = request(env.app, "POST", "/api/dashboards/create", {"name": "Ported"})
+ assert response.status_code == 200
+ assert env.telemetry.events == [("create", response.json()["id"])]
+
+
+def test_delete_uses_injected_authority_and_telemetry(env):
+ dashboard_routes.save(Dashboard(id="d1", name="Doomed"))
+ env.authority.sessions["mine"] = FakeSession("mine", "d1")
+ env.authority.sessions["other"] = FakeSession("other", "d2")
+
+ response = request(env.app, "DELETE", "/api/dashboards/d1")
+ assert response.status_code == 200
+ assert env.authority.deleted == ["mine"]
+ assert "other" in env.authority.sessions
+ assert env.telemetry.events == [("delete", "d1")]
+
+
+def test_duplicate_uses_injected_authority_for_copy_and_save(env):
+ dashboard_routes.save(Dashboard(
+ id="d1", name="Source",
+ layout={"cards": {"s1": {"session_id": "s1"}}, "expanded_session_ids": ["s1"]},
+ ))
+ env.authority.sessions["s1"] = FakeSession("s1", "d1")
+
+ response = request(env.app, "POST", "/api/dashboards/d1/duplicate")
+ assert response.status_code == 200
+ body = response.json()
+ assert set(body["layout"]["cards"]) == {"dup-s1"}
+ assert body["layout"]["expanded_session_ids"] == ["dup-s1"]
+ assert [sid for sid, _ in env.authority.saved] == ["dup-s1"]
+ assert env.telemetry.events == [("create", body["id"])]
+
+
+def test_duplicate_rolls_back_copied_sessions_when_the_dashboard_write_fails(env, monkeypatch):
+ """The sessions are copied before the new dashboard file is written; if that write fails, the copies must not survive as orphans."""
+ dashboard_routes.save(Dashboard(
+ id="d1", name="Source",
+ layout={"cards": {"s1": {"session_id": "s1"}, "s2": {"session_id": "s2"}}},
+ ))
+ env.authority.sessions["s1"] = FakeSession("s1", "d1")
+ env.authority.sessions["s2"] = FakeSession("s2", "d1")
+
+ def fail_write(path, payload):
+ raise OSError("disk full")
+ monkeypatch.setattr(dashboard_routes, "atomic_write_json", fail_write)
+
+ with pytest.raises(OSError, match="disk full"):
+ request(env.app, "POST", "/api/dashboards/d1/duplicate")
+ assert sorted(env.authority.deleted) == ["dup-s1", "dup-s2"]
+ assert env.telemetry.events == []
+ assert sorted(item.name for item in env.data_dir.iterdir()) == ["d1.json"]
+
+
+def test_duplicate_rollback_falls_back_to_purging_a_session_that_will_not_delete(env, monkeypatch):
+ dashboard_routes.save(Dashboard(id="d1", name="Source", layout={"cards": {"s1": {"session_id": "s1"}}}))
+ env.authority.sessions["s1"] = FakeSession("s1", "d1")
+
+ async def refuse_delete(session_id):
+ raise RuntimeError("delete refused")
+ env.authority.delete_session = refuse_delete
+ monkeypatch.setattr(dashboard_routes, "atomic_write_json", lambda path, payload: (_ for _ in ()).throw(OSError("disk full")))
+
+ with pytest.raises(OSError, match="disk full"):
+ request(env.app, "POST", "/api/dashboards/d1/duplicate")
+ assert env.authority.purged == ["dup-s1"]
+
+
+def test_generate_name_uses_injected_authority_and_naming(env):
+ dashboard_routes.save(Dashboard(id="d1", name="Untitled Dashboard"))
+ session = FakeSession("s1", "d1")
+ session.messages = [SimpleNamespace(role="user", content="Review the launch checklist today")]
+ env.authority.sessions["s1"] = session
+
+ response = request(env.app, "POST", "/api/dashboards/d1/generate-name")
+ assert response.status_code == 200
+ assert response.json() == {"name": "Review the launch checklist", "auto_named": True}
+
+
+def test_get_pruning_accepts_injected_authority_argument(env):
+ authority = FakeAuthority(sessions={"live1": FakeSession("live1", "d1")}, disk={"disk1": {}})
+ data = {"layout": {
+ "cards": {
+ "live1": {"session_id": "live1"},
+ "disk1": {"session_id": "disk1"},
+ "gone1": {"session_id": "gone1"},
+ "draft-x": {"session_id": "draft-x"},
+ },
+ "expanded_session_ids": ["live1", "gone1"],
+ }}
+ dashboard_routes.strip_orphan_session_cards(data, authority)
+ assert set(data["layout"]["cards"]) == {"live1", "disk1", "draft-x"}
+ assert data["layout"]["expanded_session_ids"] == ["live1"]
+
+
+# --- the lazy sibling imports must not come back ------------------------------
+
+def function_local_sibling_imports(module_path: Path) -> list[int]:
+ tree = ast.parse(module_path.read_text(encoding="utf-8"))
+ offenders: list[int] = []
+
+ def walk(node: ast.AST, in_function: bool) -> None:
+ for child in ast.iter_child_nodes(node):
+ inner = in_function or isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
+ if in_function and isinstance(child, (ast.Import, ast.ImportFrom)):
+ names = [alias.name for alias in child.names] if isinstance(child, ast.Import) else [child.module or ""]
+ if any(
+ name.startswith("backend.apps.") and not name.startswith("backend.apps.dashboards")
+ for name in names
+ ):
+ offenders.append(child.lineno)
+ walk(child, inner)
+
+ walk(tree, False)
+ return offenders
+
+
+def test_dashboards_has_no_function_local_sibling_imports():
+ repo = Path(__file__).resolve().parents[2]
+ for rel in ("backend/apps/dashboards/dashboards.py", "backend/apps/dashboards/dashboard_runtime.py"):
+ assert function_local_sibling_imports(repo / rel) == [], f"{rel} regressed to lazy sibling imports"
diff --git a/backend/tests/test_dashboards_lifecycle_characterization.py b/backend/tests/test_dashboards_lifecycle_characterization.py
new file mode 100644
index 000000000..47e95cf2c
--- /dev/null
+++ b/backend/tests/test_dashboards_lifecycle_characterization.py
@@ -0,0 +1,208 @@
+"""Characterization tests for dashboards lifecycle cross-app behavior.
+
+Pins the observable behavior of the create/delete/duplicate routes in
+backend/apps/dashboards/dashboards.py across the move of their sibling-app
+calls behind the injected dashboard_runtime boundary: telemetry emission and
+failure swallowing, owned-session removal on delete, and session copy/remap on
+duplicate. Exercised via a lifespan-free FastAPI harness; the seams patched here
+(module attributes on agent_manager, session_store, analytics client) keep
+working identically through the default adapters.
+
+Run:
+ python -m pytest backend/tests/test_dashboards_lifecycle_characterization.py -v
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from httpx import ASGITransport, AsyncClient
+
+import backend.apps.agents.agent_manager as agent_manager_module
+import backend.apps.agents.manager.session.session_store as session_store_module
+import backend.apps.service.analytics.client as analytics_module
+from backend.apps.dashboards import dashboards as dashboard_routes
+from backend.apps.dashboards.models import Dashboard
+
+
+class FakeSession:
+ def __init__(self, id, dashboard_id, browser_id=None, parent_session_id=None):
+ self.id = id
+ self.dashboard_id = dashboard_id
+ self.browser_id = browser_id
+ self.parent_session_id = parent_session_id
+ self.messages = []
+
+ def model_dump(self, mode="json"):
+ return {
+ "id": self.id,
+ "dashboard_id": self.dashboard_id,
+ "browser_id": self.browser_id,
+ "parent_session_id": self.parent_session_id,
+ }
+
+
+class FakeAgentManager:
+ def __init__(self):
+ self.sessions = {}
+ self.deleted = []
+ self.duplicated = []
+ self.purged = []
+
+ async def delete_session(self, sid):
+ self.deleted.append(sid)
+ self.sessions.pop(sid, None)
+
+ async def duplicate_session(self, sid, *, dashboard_id):
+ self.duplicated.append(sid)
+ return FakeSession(f"dup-{sid}", dashboard_id)
+
+ def purge_session_memory(self, sid):
+ self.purged.append(sid)
+
+
+@pytest.fixture
+def env(tmp_path, monkeypatch):
+ data_dir = tmp_path / "dashboards"
+ sessions_dir = tmp_path / "sessions"
+ data_dir.mkdir()
+ sessions_dir.mkdir()
+ monkeypatch.setattr(dashboard_routes, "DATA_DIR", str(data_dir))
+ monkeypatch.setattr(dashboard_routes, "SESSIONS_DIR", str(sessions_dir))
+ manager = FakeAgentManager()
+ monkeypatch.setattr(agent_manager_module, "agent_manager", manager)
+ events = []
+ monkeypatch.setattr(
+ analytics_module, "track_dashboard_event",
+ lambda *, dashboard_id, action: events.append((action, dashboard_id)),
+ )
+ saved_sessions = []
+ monkeypatch.setattr(
+ session_store_module, "save_session",
+ lambda sid, data: saved_sessions.append((sid, data)),
+ )
+ app = FastAPI()
+ app.include_router(dashboard_routes.dashboards.router, prefix="/api/dashboards")
+ return SimpleNamespace(
+ app=app, data_dir=data_dir, sessions_dir=sessions_dir,
+ manager=manager, events=events, saved_sessions=saved_sessions,
+ monkeypatch=monkeypatch,
+ )
+
+
+def request(app, method, path, json_payload=None):
+ async def go():
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ return await client.request(method, path, json=json_payload)
+ return asyncio.run(go())
+
+
+def write_session_file(sessions_dir, sid, dashboard_id):
+ (sessions_dir / f"{sid}.json").write_text(json.dumps({"id": sid, "dashboard_id": dashboard_id}))
+
+
+# --- create -------------------------------------------------------------------
+
+def test_create_emits_create_telemetry_and_persists(env):
+ response = request(env.app, "POST", "/api/dashboards/create", {"name": "Fresh"})
+ assert response.status_code == 200
+ created_id = response.json()["id"]
+ assert env.events == [("create", created_id)]
+ assert (env.data_dir / f"{created_id}.json").exists()
+
+
+def test_create_survives_telemetry_failure(env):
+ def explode(*, dashboard_id, action):
+ raise RuntimeError("telemetry down")
+ env.monkeypatch.setattr(analytics_module, "track_dashboard_event", explode)
+ response = request(env.app, "POST", "/api/dashboards/create", {"name": "Fresh"})
+ assert response.status_code == 200
+ assert (env.data_dir / f"{response.json()['id']}.json").exists()
+
+
+# --- delete -------------------------------------------------------------------
+
+def test_delete_removes_owned_disk_and_live_sessions_only(env):
+ dashboard_routes.save(Dashboard(id="d1", name="Doomed"))
+ write_session_file(env.sessions_dir, "mine", "d1")
+ write_session_file(env.sessions_dir, "other", "d2")
+ env.manager.sessions["live1"] = FakeSession("live1", "d1")
+ env.manager.sessions["live2"] = FakeSession("live2", "d2")
+
+ response = request(env.app, "DELETE", "/api/dashboards/d1")
+ assert response.status_code == 200
+ assert response.json() == {"ok": True}
+ assert not (env.sessions_dir / "mine.json").exists()
+ assert (env.sessions_dir / "other.json").exists()
+ assert env.manager.deleted == ["live1"]
+ assert not (env.data_dir / "d1.json").exists()
+ assert env.events == [("delete", "d1")]
+
+
+def test_delete_missing_dashboard_is_404_and_silent(env):
+ response = request(env.app, "DELETE", "/api/dashboards/ghost")
+ assert response.status_code == 404
+ assert env.events == []
+
+
+def test_delete_survives_telemetry_failure(env):
+ dashboard_routes.save(Dashboard(id="d1", name="Doomed"))
+
+ def explode(*, dashboard_id, action):
+ raise RuntimeError("telemetry down")
+ env.monkeypatch.setattr(analytics_module, "track_dashboard_event", explode)
+ response = request(env.app, "DELETE", "/api/dashboards/d1")
+ assert response.status_code == 200
+ assert response.json() == {"ok": True}
+
+
+# --- duplicate ----------------------------------------------------------------
+
+def test_duplicate_copies_sessions_and_remaps_layout(env):
+ dashboard_routes.save(Dashboard(
+ id="d1", name="Source",
+ layout={
+ "cards": {"s1": {"session_id": "s1", "x": 10, "y": 20}},
+ "browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "s1"}},
+ "expanded_session_ids": ["s1", "vanished"],
+ },
+ ))
+ env.manager.sessions["s1"] = FakeSession("s1", "d1", browser_id="b1")
+ write_session_file(env.sessions_dir, "s2", "d1")
+ write_session_file(env.sessions_dir, "elsewhere", "d9")
+
+ response = request(env.app, "POST", "/api/dashboards/d1/duplicate")
+ assert response.status_code == 200
+ body = response.json()
+
+ assert body["name"] == "Source (copy)"
+ assert body["id"] != "d1"
+ assert sorted(env.manager.duplicated) == ["s1", "s2"]
+ assert sorted(sid for sid, _ in env.saved_sessions) == ["dup-s1", "dup-s2"]
+
+ assert set(body["layout"]["cards"]) == {"dup-s1"}
+ assert body["layout"]["cards"]["dup-s1"]["session_id"] == "dup-s1"
+ assert body["layout"]["cards"]["dup-s1"]["x"] == 10
+
+ new_browser_cards = body["layout"]["browser_cards"]
+ assert len(new_browser_cards) == 1
+ new_bid, new_browser_card = next(iter(new_browser_cards.items()))
+ assert new_bid != "b1"
+ assert new_browser_card["browser_id"] == new_bid
+ assert new_browser_card["spawned_by"] == "dup-s1"
+
+ assert body["layout"]["expanded_session_ids"] == ["dup-s1"]
+ assert (env.data_dir / f"{body['id']}.json").exists()
+ assert env.events == [("create", body["id"])]
+
+
+def test_duplicate_missing_dashboard_is_404(env):
+ response = request(env.app, "POST", "/api/dashboards/ghost/duplicate")
+ assert response.status_code == 404
+ assert env.manager.duplicated == []
+ assert env.events == []
diff --git a/backend/tests/test_dashboards_naming_characterization.py b/backend/tests/test_dashboards_naming_characterization.py
new file mode 100644
index 000000000..0f8451dfa
--- /dev/null
+++ b/backend/tests/test_dashboards_naming_characterization.py
@@ -0,0 +1,239 @@
+"""Characterization tests for dashboard card pruning and auto-naming.
+
+Pins the observable behavior of GET /{id} orphan-card pruning and
+POST /{id}/generate-name in backend/apps/dashboards/dashboards.py across the
+move of their sibling-app calls behind the injected dashboard_runtime
+boundary: draft cards always survive, the stored file is never modified by
+pruning, naming falls back to the first four prompt words on aux failure, and
+a successful aux stream is cleaned and persisted. Seams patched here (module
+attributes on agent_manager, session_store, settings, credentials, registry,
+aux_llm) keep working identically through the default adapters.
+
+Run:
+ python -m pytest backend/tests/test_dashboards_naming_characterization.py -v
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+from fastapi import FastAPI
+from httpx import ASGITransport, AsyncClient
+
+import backend.apps.agents.agent_manager as agent_manager_module
+import backend.apps.agents.core.aux_llm as aux_llm_module
+import backend.apps.agents.manager.session.session_store as session_store_module
+import backend.apps.agents.providers.registry as registry_module
+import backend.apps.settings.credentials as credentials_module
+import backend.apps.settings.settings as settings_module
+from backend.apps.dashboards import dashboards as dashboard_routes
+from backend.apps.dashboards.models import Dashboard
+
+
+class FakeMessage:
+ def __init__(self, role, content):
+ self.role = role
+ self.content = content
+
+
+class FakeSession:
+ def __init__(self, id, dashboard_id, messages=()):
+ self.id = id
+ self.dashboard_id = dashboard_id
+ self.messages = list(messages)
+
+
+class FakeAgentManager:
+ def __init__(self):
+ self.sessions = {}
+
+
+class FakeStream:
+ def __init__(self, chunks):
+ self.chunks = chunks
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ @property
+ def text_stream(self):
+ async def gen():
+ for chunk in self.chunks:
+ yield chunk
+ return gen()
+
+
+@pytest.fixture
+def env(tmp_path, monkeypatch):
+ data_dir = tmp_path / "dashboards"
+ data_dir.mkdir()
+ monkeypatch.setattr(dashboard_routes, "DATA_DIR", str(data_dir))
+ manager = FakeAgentManager()
+ monkeypatch.setattr(agent_manager_module, "agent_manager", manager)
+ app = FastAPI()
+ app.include_router(dashboard_routes.dashboards.router, prefix="/api/dashboards")
+ return SimpleNamespace(
+ app=app, data_dir=data_dir, manager=manager, monkeypatch=monkeypatch,
+ )
+
+
+def request(app, method, path, json_payload=None):
+ async def go():
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ return await client.request(method, path, json=json_payload)
+ return asyncio.run(go())
+
+
+def card(session_id):
+ return {"session_id": session_id, "x": 1, "y": 2}
+
+
+# --- GET orphan-card pruning --------------------------------------------------
+
+def test_get_prunes_only_vanished_cards_and_never_touches_disk(env):
+ dashboard_routes.save(Dashboard(
+ id="d1", name="Board",
+ layout={
+ "cards": {
+ "live1": card("live1"),
+ "disk1": card("disk1"),
+ "gone1": card("gone1"),
+ "draft-x": card("draft-x"),
+ },
+ "expanded_session_ids": ["live1", "gone1"],
+ },
+ ))
+ env.manager.sessions["live1"] = FakeSession("live1", "d1")
+ probed = []
+
+ def fake_load_session_data(sid):
+ probed.append(sid)
+ return {} if sid == "disk1" else None
+ env.monkeypatch.setattr(session_store_module, "load_session_data", fake_load_session_data)
+ stored_before = (env.data_dir / "d1.json").read_bytes()
+
+ response = request(env.app, "GET", "/api/dashboards/d1")
+ assert response.status_code == 200
+ layout = response.json()["layout"]
+ assert set(layout["cards"]) == {"live1", "disk1", "draft-x"}
+ assert layout["expanded_session_ids"] == ["live1"]
+ assert sorted(probed) == ["disk1", "gone1"]
+ assert (env.data_dir / "d1.json").read_bytes() == stored_before
+
+
+# --- generate-name ------------------------------------------------------------
+
+def seed_named_dashboard(auto_named, name):
+ dashboard_routes.save(Dashboard(id="d1", name=name, auto_named=auto_named))
+
+
+def generate_name(env):
+ return request(env.app, "POST", "/api/dashboards/d1/generate-name")
+
+
+def test_custom_named_dashboard_is_returned_untouched(env):
+ seed_named_dashboard(auto_named=False, name="My Board")
+ response = generate_name(env)
+ assert response.status_code == 200
+ assert response.json() == {"name": "My Board", "auto_named": False}
+
+
+def test_no_matching_prompts_keeps_current_name(env):
+ seed_named_dashboard(auto_named=True, name="Old Auto Name")
+ env.manager.sessions["s9"] = FakeSession(
+ "s9", "other-dashboard", [FakeMessage("user", "Unrelated prompt")],
+ )
+ response = generate_name(env)
+ assert response.status_code == 200
+ assert response.json() == {"name": "Old Auto Name", "auto_named": True}
+
+
+def test_aux_failure_falls_back_to_first_four_prompt_words(env):
+ seed_named_dashboard(auto_named=False, name="Untitled Dashboard")
+ env.manager.sessions["s1"] = FakeSession(
+ "s1", "d1", [FakeMessage("user", "Plan a big trip to Tokyo next week")],
+ )
+
+ def explode():
+ raise RuntimeError("no settings")
+ env.monkeypatch.setattr(settings_module, "load_settings", explode)
+
+ response = generate_name(env)
+ assert response.status_code == 200
+ assert response.json() == {"name": "Plan a big trip", "auto_named": True}
+ reloaded = request(env.app, "GET", "/api/dashboards/d1").json()
+ assert reloaded["name"] == "Plan a big trip"
+ assert reloaded["auto_named"] is True
+
+
+def test_streamed_label_is_cleaned_and_persisted(env):
+ seed_named_dashboard(auto_named=True, name="Old Auto Name")
+ env.manager.sessions["s1"] = FakeSession(
+ "s1", "d1", [FakeMessage("user", "Compare hotel options for the offsite")],
+ )
+ stream_kwargs = {}
+
+ class FakeMessages:
+ def stream(self, **kwargs):
+ stream_kwargs.update(kwargs)
+ return FakeStream(["Offsite ", "Logistics"])
+
+ class FakeClient:
+ messages = FakeMessages()
+
+ async def fake_resolve_aux_model(settings, *, preferred_tier):
+ assert preferred_tier == "haiku"
+ return "haiku-model", None
+
+ env.monkeypatch.setattr(settings_module, "load_settings", lambda: SimpleNamespace())
+ env.monkeypatch.setattr(registry_module, "resolve_aux_model", fake_resolve_aux_model)
+ env.monkeypatch.setattr(
+ credentials_module, "get_anthropic_client_for_model",
+ lambda settings, model: FakeClient(),
+ )
+ env.monkeypatch.setattr(aux_llm_module, "clean_short_label", lambda text: f"Cleaned {text.strip()}")
+ env.monkeypatch.setattr(aux_llm_module, "aux_max_tokens_for", lambda model: 16)
+
+ response = generate_name(env)
+ assert response.status_code == 200
+ assert response.json() == {"name": "Cleaned Offsite Logistics", "auto_named": True}
+ assert stream_kwargs["model"] == "haiku-model"
+ assert stream_kwargs["max_tokens"] == 16
+ reloaded = request(env.app, "GET", "/api/dashboards/d1").json()
+ assert reloaded["name"] == "Cleaned Offsite Logistics"
+
+
+def test_empty_cleaned_label_keeps_first_words_fallback(env):
+ seed_named_dashboard(auto_named=True, name="Old Auto Name")
+ env.manager.sessions["s1"] = FakeSession(
+ "s1", "d1", [FakeMessage("user", "Draft the quarterly budget review deck")],
+ )
+
+ class FakeMessages:
+ def stream(self, **kwargs):
+ return FakeStream([" "])
+
+ class FakeClient:
+ messages = FakeMessages()
+
+ async def fake_resolve_aux_model(settings, *, preferred_tier):
+ return "haiku-model", None
+
+ env.monkeypatch.setattr(settings_module, "load_settings", lambda: SimpleNamespace())
+ env.monkeypatch.setattr(registry_module, "resolve_aux_model", fake_resolve_aux_model)
+ env.monkeypatch.setattr(
+ credentials_module, "get_anthropic_client_for_model",
+ lambda settings, model: FakeClient(),
+ )
+ env.monkeypatch.setattr(aux_llm_module, "clean_short_label", lambda text: text.strip())
+ env.monkeypatch.setattr(aux_llm_module, "aux_max_tokens_for", lambda model: 16)
+
+ response = generate_name(env)
+ assert response.status_code == 200
+ assert response.json() == {"name": "Draft the quarterly budget", "auto_named": True}
diff --git a/backend/tests/test_ws_integration.py b/backend/tests/test_ws_integration.py
index 741cc223f..33f86210e 100644
--- a/backend/tests/test_ws_integration.py
+++ b/backend/tests/test_ws_integration.py
@@ -6,6 +6,8 @@
The SDK and WS auth are mocked; everything else is the real running stack."""
import asyncio
+import queue
+import threading
import pytest
@@ -17,6 +19,29 @@
import backend.main as main_mod
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.models import AgentSession
+import backend.apps.agents.manager.run.RunOptions as run_options_mod
+
+
+def p_receive_json(ws, timeout: float = 5.0):
+ """ws.receive_json() blocks forever when the event never comes; a regression in the loop then
+ hangs the whole pytest run instead of failing this test. Bound every receive."""
+ out = queue.Queue(maxsize=1)
+
+ def p_recv():
+ try:
+ out.put((True, ws.receive_json()))
+ except BaseException as exc:
+ out.put((False, exc))
+
+ thread = threading.Thread(target=p_recv, daemon=True)
+ thread.start()
+ try:
+ ok, value = out.get(timeout=timeout)
+ except queue.Empty as exc:
+ raise AssertionError(f"timed out waiting for websocket event after {timeout}s") from exc
+ if ok:
+ return value
+ raise value
def p_assistant():
@@ -33,6 +58,20 @@ def p_result():
def test_ws_endpoint_streams_a_full_turn_end_to_end(monkeypatch):
monkeypatch.setattr(main_mod, "p_ws_auth_ok", lambda ws: True, raising=True)
+ # The contract of this test is "SDK and WS auth mocked, everything else real", but two things on
+ # the turn path reach outside the process and must not decide the outcome: configure_provider_env
+ # can wander into 9Router revival (spawn/npm install, serialized on a module-level lock) whenever
+ # earlier tests left provider evidence behind, and the background turn-label aux call does the
+ # same. Pin both out; the persistent-client path is pinned off suite-wide in conftest.
+ async def p_noop_provider_env(*args, **kwargs):
+ return None
+
+ async def p_noop_turn_label(*args, **kwargs):
+ return None
+
+ monkeypatch.setattr(run_options_mod, "configure_provider_env", p_noop_provider_env, raising=True)
+ monkeypatch.setattr(agent_manager, "generate_turn_label", p_noop_turn_label, raising=True)
+
async def fake_query(*args, **kwargs):
yield p_assistant()
yield p_result()
@@ -49,10 +88,15 @@ async def fake_query(*args, **kwargs):
ws.send_json({"event": "agent:send_message", "data": {"prompt": "hi"}})
seen = []
for _ in range(40):
- ev = ws.receive_json()
+ ev = p_receive_json(ws)
seen.append(ev.get("event"))
- if ev.get("event") == "agent:message" and "hello from the loop" in str(ev.get("data", {})):
+ if (
+ ev.get("event") == "agent:status"
+ and ev.get("data", {}).get("status") == "completed"
+ ):
break
+ else:
+ raise AssertionError(f"did not receive completed status; saw events={seen}")
# the real loop's assistant reply made it all the way back over the WS
assert "agent:message" in seen
assert any(m.role == "assistant" and "hello from the loop" in str(m.content)
diff --git a/frontend/scripts/run-tests.mjs b/frontend/scripts/run-tests.mjs
new file mode 100644
index 000000000..8fad6fe2d
--- /dev/null
+++ b/frontend/scripts/run-tests.mjs
@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+// Runs every renderer unit test (src/**/*.test.ts, *.test.tsx) under node:test, with tsx doing the
+// TypeScript. One command for CI and for a dev machine: `node scripts/run-tests.mjs`, optionally
+// followed by file paths to run a subset. Exits non-zero if any test fails or nothing was found.
+import { spawnSync } from 'node:child_process';
+import { readdirSync, statSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const testFile = (p) => /\.test\.tsx?$/.test(p);
+
+function walk(dir, out) {
+ for (const name of readdirSync(dir)) {
+ if (name === 'node_modules' || name === 'dist') continue;
+ const p = join(dir, name);
+ if (statSync(p).isDirectory()) walk(p, out);
+ else if (testFile(p)) out.push(p);
+ }
+ return out;
+}
+
+const files = process.argv.length > 2 ? process.argv.slice(2) : walk(join(root, 'src'), []).sort();
+if (files.length === 0) {
+ console.error('run-tests: no test files found under src/');
+ process.exit(1);
+}
+const result = spawnSync(process.execPath, ['--import', 'tsx', '--test', ...files], { cwd: root, stdio: 'inherit' });
+process.exit(result.status ?? 1);
diff --git a/frontend/src/shared/backendConnection.ts b/frontend/src/shared/backendConnection.ts
index e93237e10..c410bfc04 100644
--- a/frontend/src/shared/backendConnection.ts
+++ b/frontend/src/shared/backendConnection.ts
@@ -80,4 +80,7 @@ export function noteRequestStalled(): void {
}
// Harness/debug handle: lets a live session (CDP, support) read the signal without a store import.
-(window as unknown as { __OSW_CONN?: object }).__OSW_CONN = { backendReachable, onBackendReachability };
+// Guarded: reducers that import this module also run under node:test, where there is no window.
+if (typeof window !== 'undefined') {
+ (window as unknown as { __OSW_CONN?: object }).__OSW_CONN = { backendReachable, onBackendReachability };
+}
diff --git a/frontend/src/shared/config.ts b/frontend/src/shared/config.ts
index c0aec765f..f92bbe614 100644
--- a/frontend/src/shared/config.ts
+++ b/frontend/src/shared/config.ts
@@ -1,6 +1,9 @@
import { noteBackendFailure, noteBackendSuccess, noteRequestStalled, setBackendProber } from '@/shared/backendConnection';
-const _w = window as any;
+// Import-safe outside a renderer: reducers that import API_BASE also run under node:test, where there
+// is no window. The module then answers with the defaults and installs nothing.
+const hasWindow = typeof window !== 'undefined';
+const _w = (hasWindow ? window : {}) as any;
// Prefer the preload-injected port; if it's missing (preload raced the backend port being picked), re-query the live value before falling back to 8324. The bare 8324 guess is wrong on any machine where the backend landed on a fallback port (e.g. 8324 was held by a leftover backend); see the self-heal below.
const port =
_w.__OPENSWARM_PORT__ ||
@@ -8,7 +11,7 @@ const port =
? _w.openswarm.getBackendPortLive()
: 0) ||
8324;
-const host = window.location.hostname || 'localhost';
+const host = (hasWindow && window.location.hostname) || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
@@ -217,5 +220,7 @@ function _installAuthFetchInterceptor() {
};
}
-_installAuthFetchInterceptor();
-ensureAuthToken();
+if (hasWindow) {
+ _installAuthFetchInterceptor();
+ ensureAuthToken();
+}
diff --git a/frontend/src/shared/safeMode.ts b/frontend/src/shared/safeMode.ts
index c8c3f6e93..432fb7691 100644
--- a/frontend/src/shared/safeMode.ts
+++ b/frontend/src/shared/safeMode.ts
@@ -11,7 +11,10 @@ export interface SafeModeInfo {
let cached: SafeModeInfo = { safeMode: false, dirtyCount: 0, fingerprint: null };
-const api = (window as unknown as { openswarm?: { getSafeMode?: () => Promise } }).openswarm;
+// Import-safe outside a renderer: the layout slice imports this, and its reducer tests run under node:test.
+const api = typeof window === 'undefined'
+ ? undefined
+ : (window as unknown as { openswarm?: { getSafeMode?: () => Promise } }).openswarm;
if (api?.getSafeMode) {
void api.getSafeMode().then((info) => {
if (info && typeof info.safeMode === 'boolean') cached = info;