"""Smoke test for the Synapse backend's core wiring. Run from nexus-core/ with the Promethean venv active: pytest -q Or the whole gate (tests + frontend lint): bin/check.sh Deliberately tiny (ponytail): it guards the things v1 promises - app wiring, model defaults, playbook ordering, persistence - without needing a running Ollama service or network. Not a full suite. """ import subprocess from pathlib import Path from fastapi.testclient import TestClient import pytest from synapse.main import app from synapse.memory.store import MemoryItem, PersistentMemoryStore from synapse.nexus_config import DEFAULT_CHAT_MODEL, DEFAULT_MEMORY_MODEL from synapse.ollama_manager import OllamaManager from synapse import ollama_manager from synapse.icons.compositor import _is_allowed_path from synapse import playbook_manager from synapse.playbooks.store import PlaybookFileStore, PlaybookItem REPO_ROOT = Path(__file__).resolve().parent.parent def test_app_builds_and_status_responds(): # No `with` → startup event does not run; `ollama` stays the module-level # None and /status handles it. Pure liveness check. Not `/` — since the # single-process change that path serves the built web UI, not JSON. resp = TestClient(app).get("/status") assert resp.status_code == 200 assert resp.json()["status"] == "online" def test_keep_alive_pins_the_model(): # Default keeps the model resident so chats skip cold reloads... assert PersistentMemoryStore._SETTINGS_DEFAULTS["keep_alive"] == "30m" mgr = OllamaManager() assert mgr._apply_keep_alive({"model": "x"}) == {"model": "x", "keep_alive": "30m"} # ...and an empty value omits the field (falls back to Ollama's default). mgr.keep_alive = "" assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"}) def test_auto_model_remap(monkeypatch): import asyncio from synapse import main cfg = {"model": "", "auto_chat_model": "chatX", "auto_code_model": "coderY"} monkeypatch.setattr(main.store, "get_settings", lambda: cfg) # code intent ("function") routes to the code remap; chat intent to the chat remap assert asyncio.run(main._auto_select_model("write a function to sort a list")) == "coderY" assert asyncio.run(main._auto_select_model("how are you today")) == "chatX" # an explicit pin beats the remap cfg["model"] = "pinnedZ" assert asyncio.run(main._auto_select_model("debug this code")) == "pinnedZ" def test_hardware_fit_logic(): from synapse import hardware assert hardware._fit(2.5, 4.0, 16.0) == "gpu" # 2.5+1 <= 4 -> fits GPU assert hardware._fit(4.7, 4.0, 16.0) == "ram" # too big for 4GB GPU, fits RAM assert hardware._fit(4.7, None, 16.0) == "ram" # VRAM unknown -> RAM assert hardware._fit(40.0, 4.0, 16.0) == "no" # too big everywhere rec = hardware.recommend() assert "hardware" in rec and all("fit" in m for m in rec["models"]) def test_stt_status_endpoint(): # Reports whether local Whisper is installed; wiring must respond either way. resp = TestClient(app).get("/stt/status") assert resp.status_code == 200 assert isinstance(resp.json()["available"], bool) def test_num_ctx_option_only_when_positive(): # 0 / None -> omit num_ctx so Ollama uses the model default; positive -> set it. from synapse.ollama_manager import _chat_options assert "num_ctx" not in _chat_options(None, None, 0) assert "num_ctx" not in _chat_options(None, None, None) assert _chat_options(None, None, 8192)["num_ctx"] == 8192 def test_default_models_have_one_source_of_truth(): # The curator default is the config constant, not a copy of it. assert PersistentMemoryStore._SETTINGS_DEFAULTS["memory_model"] == DEFAULT_MEMORY_MODEL # And the Windows installer reads the constants instead of hardcoding a tag, # which is what used to let installer and backend drift onto different models. ps1 = (REPO_ROOT / "install-windows.ps1").read_text(encoding="utf-8") assert "DEFAULT_CHAT_MODEL" in ps1 for hardcoded in (DEFAULT_CHAT_MODEL, DEFAULT_MEMORY_MODEL, "qwen", "llama3.1"): assert hardcoded not in ps1, f"install-windows.ps1 hardcodes {hardcoded!r}" def test_windows_scripts_stay_ascii(): # PowerShell 5.1 decodes BOM-less files as ANSI: one stray Unicode dash # eats a quote and the whole script dies at parse time. cmd.exe is worse # still - it decodes by the console codepage. Globbed rather than listed by # name so a newly added script is covered without editing this test. skip = {"Promethean", "node_modules", ".git", "dist"} ps1s = [p for pat in ("*.ps1", "*.cmd") for p in REPO_ROOT.rglob(pat) if not skip & set(p.parts)] assert ps1s, "no .ps1/.cmd files found - did the Windows path move?" for path in ps1s: raw = path.read_bytes() bad = [(i, b) for i, b in enumerate(raw) if b > 0x7F] assert not bad, f"{path.relative_to(REPO_ROOT)} has non-ASCII bytes at {bad[:3]}" def test_ncp_is_registered_on_path_not_in_a_shell_profile(): """A `function ncp` in a shell profile is invisible to cron, .desktop Exec lines, cmd.exe and Task Scheduler - and on Windows the default Restricted execution policy blocks the profile outright. Both installers must put ncp on PATH; the profile wiring only survives as a no-sudo fallback.""" linux = (REPO_ROOT / "bin" / "restore-linux.sh").read_text(encoding="utf-8") runtime = linux.split('if [ "$stage" = "runtime" ]; then')[1].split("\n exit 0\nfi")[0] assert "/usr/local/bin/ncp" in runtime ps1 = (REPO_ROOT / "install-windows.ps1").read_text(encoding="utf-8") assert "ncp.cmd" in ps1, "installer must register the .cmd shim, not a profile function" # setx truncates PATH at 1024 characters and has permanently broken machines. # Comments stripped so the code that explains the ban does not trip it. code = "\n".join(ln for ln in ps1.splitlines() if not ln.strip().startswith("#")) assert "setx" not in code.lower() assert 'SetEnvironmentVariable("Path"' in code # The model pull is the only multi-GB step and the only one the script # invites a Ctrl+C on -- which in PS 5.1 kills the whole script. Anything # after it is lost, so it has to come last. It used to sit in the middle, # and skipping the download silently skipped the ncp registration too. assert ps1.index("ollama pull") > ps1.index("Registering the ncp command"), \ "model pull must come after ncp registration - a Ctrl+C there aborts the installer" assert ps1.index("ollama pull") > ps1.index("Creating desktop shortcut"), \ "model pull must come after the desktop shortcut" # And it must not TELL anyone to press Ctrl+C: in PS 5.1 that kills the # script, so the advertised way to skip the download was also the way to # abort the install. Skipping is a prompt now. Comments stripped so the # comment explaining this does not trip the check. assert "Ctrl+C" not in code, "installer must not offer Ctrl+C as a skip" def test_no_ps1_shadows_the_ncp_path_shim(): """PowerShell resolves ExternalScript (.ps1) ahead of Application (.cmd), so an ncp.ps1 sitting next to ncp.cmd wins in PowerShell and drags the execution policy back in - the exact thing the .cmd exists to avoid. Observed on the Windows VM: `Get-Command ncp -All` listed ncp.ps1 first, from the same directory the installer had just put on PATH.""" shim = REPO_ROOT / "management" / "ncp.cmd" assert shim.exists(), "the Windows PATH shim is missing" twin = shim.with_suffix(".ps1") assert not twin.exists(), f"{twin.name} shadows {shim.name} in PowerShell" def test_first_playbook_is_the_system_prompt(tmp_path, monkeypatch): store = PlaybookFileStore(tmp_path) store.add_playbook(PlaybookItem(id="ctx", title="Reference", goal="ref goal", instructions="ref instructions", order=1)) store.add_playbook(PlaybookItem(id="main", title="Main", goal="Be useful.", instructions="Answer briefly.", order=0)) monkeypatch.setattr("synapse.playbook_manager.playbook_store", store) assert playbook_manager.get_main_playbook().id == "main" assert [p.id for p in playbook_manager.get_context_playbooks()] == ["ctx"] assert playbook_manager.get_system_prompt() == "Be useful.\n\nAnswer briefly." def test_settings_round_trip_over_defaults(tmp_path): store = PersistentMemoryStore(tmp_path / "memory.db") store.update_settings({"model": "some-model:8b"}) settings = store.get_settings() assert settings["model"] == "some-model:8b" # written value wins assert settings["memory_model"] == DEFAULT_MEMORY_MODEL # untouched keys still default def test_past_conversations_are_searchable(tmp_path): # The chat system prompt is built from these snippets, so a broken search # silently drops the assistant's recall of past chats. store = PersistentMemoryStore(tmp_path / "memory.db") store.create_conversation("c1") store.add_message("c1", "user", "how do I mount the backup drive?") store.add_message("c1", "assistant", "use rsync over ssh") assert store.search_conversations("BACKUP drive") # case-insensitive substring assert store.search_conversations("nothing here") == [] assert store.search_conversations(" ") == [] def test_memory_store_reads_changes_from_another_instance(tmp_path): db_path = tmp_path / "memory.db" writer = PersistentMemoryStore(db_path) reader = PersistentMemoryStore(db_path) writer.add(MemoryItem(id="fresh", text="written by another process")) assert reader.get("fresh").text == "written by another process" def test_icon_source_requires_real_allowed_file_boundary(tmp_path): allowed = tmp_path / "icons" allowed.mkdir() source = allowed / "app.svg" source.write_text("") sibling = allowed.parent / "icons-other" sibling.mkdir() (sibling / "app.svg").write_text("") old_roots = list(__import__("synapse.icons.compositor", fromlist=["_ALLOWED_ROOTS"])._ALLOWED_ROOTS) module = __import__("synapse.icons.compositor", fromlist=["_ALLOWED_ROOTS"]) module._ALLOWED_ROOTS[:] = [str(allowed)] try: assert _is_allowed_path(str(source)) assert not _is_allowed_path(str(sibling / "app.svg")) assert not _is_allowed_path(str(allowed / "missing.svg")) finally: module._ALLOWED_ROOTS[:] = old_roots def test_ollama_stream_propagates_transport_errors(monkeypatch): class FailingResponse: async def __aenter__(self): return self async def __aexit__(self, *args): return False def raise_for_status(self): raise RuntimeError("Ollama unavailable") class FailingClient: def __init__(self, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, *args): return False def stream(self, *args, **kwargs): return FailingResponse() monkeypatch.setattr(ollama_manager.httpx, "AsyncClient", FailingClient) async def consume(): async for _ in OllamaManager()._chat_stream([], "model", 0): pass with pytest.raises(RuntimeError, match="Ollama unavailable"): import asyncio asyncio.run(consume()) def _load_sync(): """bin/ isn't a package - load the cross-platform sync core by path.""" import importlib.util spec = importlib.util.spec_from_file_location("sync", REPO_ROOT / "bin" / "sync.py") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def test_sync_compare_detects_direction(tmp_path): """The guard that stops a stale box from overwriting the other's chats. Both backup and restore refuse to run when this says the wrong thing, so a silent break here loses conversation history.""" import sqlite3 as sq sync = _load_sync() db, dump = tmp_path / "memory.db", tmp_path / "memory.db.sql" def write(rows): db.unlink(missing_ok=True) with sq.connect(db) as conn: # updated_at REAL, matching the production schema in store.py. A TEXT # column here hid a real TypeError for months: the comparison in # _extra() ran str-vs-str in the test and str-vs-float in the field. conn.executescript( "create table conversations (id text primary key, updated_at real not null);" "create table memory (id text primary key);" ) conn.executemany("insert into conversations values (?, ?)", rows) assert sync.compare(db, dump) == "no-dump" write([("a", 1778553309.5)]) assert sync.compare(db, dump) == "no-dump" # Dump matches the live DB exactly. sync.DB, sync.DB_SQL = db, dump assert sync.dump_db() assert sync.compare(db, dump) == "same" # A newer message bumps updated_at -> this box is ahead of the backup. write([("a", 1778553999.5)]) assert sync.compare(db, dump) == "local-ahead" # The backup holds a conversation this box never saw. write([]) assert sync.compare(db, dump) == "local-behind" # Each side has something the other lacks. write([("b", 1778553309.5)]) assert sync.compare(db, dump) == "diverged" # A dump that won't replay is its own verdict, not a fake divergence - # reporting "diverged" there blocked backup AND restore with what looked # like a legitimate answer. dump.write_text("INSERT INTO nope VALUES (1);\n") assert sync.compare(db, dump) == "unreadable" db.unlink() assert sync.compare(db, dump) == "no-live" def test_restore_stages_match_the_script(): """sync.py invokes restore-linux.sh stages by name with check=False, so a rename on one side alone fails silently - and the runtime stage is what installs Ollama and the ncp alias. Keep the two in agreement.""" import re called = set(re.findall( r'linux_stage\(\s*"restore-linux\.sh"\s*,\s*"(\w+)"', (REPO_ROOT / "bin" / "sync.py").read_text())) script = (REPO_ROOT / "bin" / "restore-linux.sh").read_text() handled = set(re.search(r"case \"\$stage\" in\s*\n\s*([\w|]+)\)", script).group(1).split("|")) assert called, "no restore-linux.sh stages found in sync.py" assert called <= handled, f"sync.py calls unhandled stage(s): {called - handled}" def test_desktop_stage_is_the_only_one_touching_home(): """--no-desktop is only a real safety valve if the $HOME writes all live in the desktop stage. A test clone runs prep + runtime unconditionally.""" script = (REPO_ROOT / "bin" / "restore-linux.sh").read_text() runtime = script.split('if [ "$stage" = "runtime" ]; then')[1].split("\n exit 0\nfi")[0] # Shell wiring is the one deliberate exception: registering ncp in ~/.bashrc # and in the PowerShell profile is how you launch NexusOS at all, and each is # a grep-guarded no-op on re-run. Desktop config (xfconf, plank, themes, # os-release) must stay in the desktop stage so --no-desktop really is safe. shell_wiring = (".bashrc",) home_writes = [ln for ln in runtime.splitlines() if "$HOME" in ln and not any(w in ln for w in shell_wiring)] assert not home_writes, f"runtime stage writes to $HOME: {home_writes}" def test_genmon_configs_are_written_with_the_panel_down(): """genmon holds its config in memory and rewrites genmon-N.rc when the panel exits, so copying the rc files while the panel is running gets silently undone - every applet then loads blank. The copy has to sit between the panel quit and the relaunch.""" script = (REPO_ROOT / "bin" / "panel" / "install.sh").read_text() quit_at = script.index("xfce4-panel -q") copy_at = script.index('cp -f "$NEXUS/management/panel/genmon-$id.rc"') start_at = script.index("setsid xfce4-panel") assert quit_at < copy_at < start_at, "genmon rc copy must happen with the panel stopped" def test_plank_is_actually_launched(): """Restoring ~/.config/plank only brings back the dock's launchers - nothing in it starts Plank. The primary-follow watcher is what launches and revives it, so it needs an autostart entry or a fresh box has no dock at all.""" desktop = REPO_ROOT / "management" / "autostart" / "plank.desktop" assert "plank-primary-watch.sh" in desktop.read_text() assert "plank.desktop" in (REPO_ROOT / "bin" / "panel" / "install.sh").read_text() _VULKANINFO_IGPU_AND_LLVMPIPE = """\ Devices: ======== GPU0: \tvendorID = 0x8086 \tdeviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU \tdeviceName = Intel(R) Graphics (RPL-S) GPU1: \tvendorID = 0x10005 \tdeviceType = PHYSICAL_DEVICE_TYPE_CPU \tdeviceName = llvmpipe (LLVM 20.1.2, 256 bits) """ def test_software_rasterizer_is_never_picked_as_a_gpu(monkeypatch): """Mesa always advertises an llvmpipe device with deviceType CPU. It used to outscore an integrated GPU (neither DISCRETE nor INTEGRATED scored higher than INTEGRATED), so Ollama got pinned to a software rasterizer - CPU inference with Vulkan overhead stacked on top, reported as a 31 GiB 'discrete' GPU. The iGPU has to win, and a box with nothing but rasterizers has to report no Vulkan device at all.""" def fake_run(cmd, **kwargs): return subprocess.CompletedProcess(cmd, 0, _VULKANINFO_IGPU_AND_LLVMPIPE, "") monkeypatch.setattr(ollama_manager.subprocess, "run", fake_run) idx, name = ollama_manager._best_vulkan_device() assert idx == 0 and "Intel" in name, f"picked {name!r} over the iGPU" monkeypatch.setattr( ollama_manager.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, "Devices:\nGPU0:\n" "\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n" "\tdeviceName = llvmpipe\n", "")) assert ollama_manager._best_vulkan_device()[0] == -1, "rasterizer-only box must report no GPU" def test_installed_model_lookup_normalizes_the_latest_tag(): """Ollama resolves a bare name to ":latest", so a catalog entry written untagged ("nomic-embed-text") never matched the installed name ("nomic-embed-text:latest") and the Required gate stayed locked forever - the model pulls fine, the UI just never sees it. Every lookup has to go through withTag().""" jsx = (REPO_ROOT / "interface" / "web" / "src" / "Models.jsx").read_text() assert 'const withTag =' in jsx assert "installedNames.has(m.name.toLowerCase())" not in jsx, \ "raw name lookup is back; untagged catalog entries will read as missing" def test_dump_round_trips_a_db_holding_vec_tables(tmp_path): """The memory dump is the ONLY backup of conversations, facts and history. iterdump() serializes a sqlite_vec virtual table as a raw INSERT INTO sqlite_master(...) plus inserts into a table the replaying connection cannot see, so restoring died on "no such table: vec_messages" and left zero tables - the entire backup was unrecoverable. Build a DB shaped like production (vec table included) and prove the dump replays.""" import sqlite3 as sq sqlite_vec = pytest.importorskip("sqlite_vec", reason="vec index is optional") sync = _load_sync() if sync._vec0_extension() is None: # dump_db() resolves vec0 relative to the repo's own venv, so a clone # whose Promethean isn't built yet cannot dump a DB holding vec tables. pytest.skip("sqlite_vec extension not present under this repo's venv") db, dump = tmp_path / "memory.db", tmp_path / "memory.db.sql" conn = sq.connect(db) conn.enable_load_extension(True) sqlite_vec.load(conn) conn.enable_load_extension(False) conn.executescript( "create table conversations (id text primary key, updated_at real not null);" "create table memory (id text primary key);" "create table messages (id integer primary key, body text);" "create virtual table vec_messages using vec0(embedding float[3] distance_metric=cosine);" ) conn.execute("insert into conversations values ('c1', 1778553309.5)") conn.execute("insert into memory values ('m1')") # A message whose text mentions the filtered table names: a filter applied to # the dump TEXT instead of the statement stream would eat this row. conn.execute("insert into messages values (1, 'debugging vec_messages and vec_documents')") conn.execute("insert into vec_messages(rowid, embedding) values (1, ?)", (sqlite_vec.serialize_float32([0.1, 0.2, 0.3]),)) conn.commit() conn.close() sync.DB, sync.DB_SQL = db, dump assert sync.dump_db() restored = tmp_path / "restored.db" with sq.connect(restored) as out: out.executescript(dump.read_text(encoding="utf-8")) # must not raise got = sq.connect(restored) assert got.execute("select count(*) from conversations").fetchone()[0] == 1 assert got.execute("select count(*) from memory").fetchone()[0] == 1 assert got.execute("select body from messages").fetchone()[0] == \ "debugging vec_messages and vec_documents" # The vec index is derived - absent from the dump, rebuilt by the backfill. assert "vec_messages" not in {r[0] for r in got.execute( "select name from sqlite_master where type='table'")} def test_curator_drops_fabricated_facts(): """The curator model invents two classes of fact no prompt wording stopped (verified against mistral:7b), and both reached the real memory DB: absence claims read off the existing-memory block ("the user does not have any pets", which contradicted four cats on file) and specifics lifted from the ASSISTANT's reply ("the user's main development machine is a MacBook Pro", from the user message "What am I developing on?"). Deterministic guard, so it holds whatever the model does.""" from synapse.memory.extractor import _reject_reason # Absence claims are never facts. assert _reject_reason("the user does not have any pets", "do i have any pets?") assert _reject_reason("the user's favorite episode is unknown", "what's my favorite episode?") assert _reject_reason("the user has not specified an interest", "tell me about stargate") # Specifics the user never typed came from the assistant. assert _reject_reason("the user's main dev machine is a MacBook Pro", "What am I developing on?") # ...but the same shape grounded in the user's own words must survive. assert _reject_reason( "the user owns a 2000 Ford Ranger with a 3.0L V6", "i also have a 2000 Ford Ranger, it's a five-speed with a 3.0L V6") is None assert _reject_reason( "the user has a beagle named Biscuit", "i just adopted a dog named Biscuit, he's a beagle") is None # A fact carrying no proper nouns or numbers can't be grounding-checked; # the prompt owns that case, so the guard must let it through. assert _reject_reason( "the user prefers short answers over long explanations", "i really prefer short answers over long explanations") is None def test_update_check_reports_behind_and_survives_git_failure(monkeypatch): from synapse import main # Fake git so the test never touches the network. Behind → the remote # VERSION file, not this checkout's, is what the UI advertises. calls = { ("rev-list", "--count", "HEAD..origin/main"): "3", ("show", "origin/main:VERSION"): "9.9.9\n", ("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing", } monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, "")) body = TestClient(app).get("/update/check").json() assert body["behind"] == 3 and body["remote_version"] == "9.9.9" # An unreachable remote must not 500 the sidebar. def boom(*a, **kw): raise RuntimeError("could not resolve host") monkeypatch.setattr(main, "_git", boom) body = TestClient(app).get("/update/check").json() assert body["behind"] == 0 and "could not resolve host" in body["error"] def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch): import subprocess from synapse import main seen = {} def fake_popen(argv, **kw): seen["argv"], seen["kw"] = argv, kw return object() monkeypatch.setattr(main, "_update_running", False) monkeypatch.setattr(subprocess, "Popen", fake_popen) client = TestClient(app) assert client.post("/update/apply").json()["started"] is True assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"] # Detached, or `ncp upgrade` dies with the backend it is about to stop. assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags") # Double-click must not launch a second pull/rebuild over the first. assert client.post("/update/apply").json()["started"] is False