forked from enderofwings/NexusOS
Use contextlib.closing for dump, comparison, and restore connections so Windows can unlink the live database immediately after the comparison step.
540 lines
25 KiB
Python
540 lines
25 KiB
Python
"""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("<svg />")
|
|
sibling = allowed.parent / "icons-other"
|
|
sibling.mkdir()
|
|
(sibling / "app.svg").write_text("<svg />")
|
|
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 contextlib
|
|
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)
|
|
# closing() then the connection itself: sqlite3's own context manager
|
|
# commits but never closes, and Windows refuses to unlink a file that
|
|
# still has an open handle.
|
|
with contextlib.closing(sq.connect(db)) as conn, 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
|