1928 lines
66 KiB
Python
1928 lines
66 KiB
Python
import json
|
|
import os
|
|
import socket
|
|
import stat
|
|
import threading
|
|
import time
|
|
import zipfile
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
import plugins.memory.openviking as openviking_module
|
|
from hermes_cli import __version__ as _HERMES_VERSION
|
|
from plugins.memory.openviking import (
|
|
OpenVikingMemoryProvider,
|
|
_DEFERRED_COMMIT_TIMEOUT,
|
|
_VikingClient,
|
|
)
|
|
|
|
_EXPECTED_USER_AGENT = f"openviking-memory-hermes/{_HERMES_VERSION}"
|
|
|
|
|
|
def _clear_openviking_tenant_env(monkeypatch):
|
|
for name in ("OPENVIKING_ACCOUNT", "OPENVIKING_USER", "OPENVIKING_AGENT"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_openviking_home(tmp_path, monkeypatch):
|
|
home = tmp_path / "home"
|
|
monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: home))
|
|
|
|
|
|
def _clear_openviking_env(monkeypatch):
|
|
for key in (
|
|
"OPENVIKING_ENDPOINT",
|
|
"OPENVIKING_API_KEY",
|
|
"OPENVIKING_ACCOUNT",
|
|
"OPENVIKING_USER",
|
|
"OPENVIKING_AGENT",
|
|
"OPENVIKING_CLI_CONFIG_FILE",
|
|
"OPENVIKING_PROFILE_TOKEN_BUDGET",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
|
|
def _prompt_from_values(values: dict[str, str], *, forbidden: set[str] | None = None):
|
|
forbidden = forbidden or set()
|
|
|
|
def _prompt(label, default=None, secret=False):
|
|
if label in forbidden:
|
|
raise AssertionError(f"{label} should not be prompted")
|
|
return values.get(label, default or "")
|
|
|
|
return _prompt
|
|
|
|
|
|
def _allow_setup_validation(monkeypatch, *, root_access: bool = False):
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_reachability",
|
|
lambda endpoint: (True, ""),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_auth",
|
|
lambda values: (True, ""),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_root_access",
|
|
lambda values: (root_access, "" if root_access else "Requires role: root"),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_setup_values",
|
|
lambda values, *, require_api_key=False: (
|
|
True,
|
|
"",
|
|
"root" if root_access else ("user" if values.get("api_key") else None),
|
|
),
|
|
raising=False,
|
|
)
|
|
|
|
|
|
def test_openviking_provider_config_loader_uses_readonly_config(monkeypatch):
|
|
import hermes_cli.config as config_mod
|
|
|
|
calls = []
|
|
backing_config = {
|
|
"memory": {
|
|
"openviking": {
|
|
"endpoint": "http://127.0.0.1:19472",
|
|
"api_key": "test-key",
|
|
}
|
|
}
|
|
}
|
|
|
|
def load_config_readonly():
|
|
calls.append("readonly")
|
|
return backing_config
|
|
|
|
def load_config():
|
|
raise AssertionError("OpenViking config loader should use readonly config")
|
|
|
|
monkeypatch.setattr(config_mod, "load_config_readonly", load_config_readonly)
|
|
monkeypatch.setattr(config_mod, "load_config", load_config)
|
|
|
|
config = openviking_module._load_hermes_openviking_config()
|
|
|
|
assert calls == ["readonly"]
|
|
assert config == {
|
|
"endpoint": "http://127.0.0.1:19472",
|
|
"api_key": "test-key",
|
|
}
|
|
assert config is not backing_config["memory"]["openviking"]
|
|
|
|
|
|
def test_connection_settings_read_dashboard_config_file(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "config.yaml").write_text(
|
|
"""\
|
|
memory:
|
|
provider: openviking
|
|
openviking:
|
|
endpoint: http://saved.test:1933
|
|
account: saved-account
|
|
user: saved-user
|
|
agent: saved-agent
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
settings = openviking_module._resolve_connection_settings(
|
|
openviking_module._load_hermes_openviking_config()
|
|
)
|
|
|
|
assert settings["endpoint"] == "http://saved.test:1933"
|
|
assert settings["account"] == "saved-account"
|
|
assert settings["user"] == "saved-user"
|
|
assert settings["agent"] == "saved-agent"
|
|
assert settings["api_key"] == ""
|
|
|
|
|
|
def test_linked_ovcli_config_is_read_at_runtime(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
ovcli_path = tmp_path / "ovcli.conf"
|
|
ovcli_path.write_text(
|
|
json.dumps({
|
|
"url": "http://openviking-one.test",
|
|
"api_key": "key-one",
|
|
"account": "acct-one",
|
|
"user": "alice",
|
|
"agent_id": "agent-one",
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
provider_config = {"use_ovcli_config": True, "ovcli_config_path": str(ovcli_path)}
|
|
|
|
settings = openviking_module._resolve_connection_settings(provider_config)
|
|
|
|
assert settings == {
|
|
"endpoint": "http://openviking-one.test",
|
|
"api_key": "key-one",
|
|
"account": "",
|
|
"user": "",
|
|
"agent": "agent-one",
|
|
}
|
|
|
|
ovcli_path.write_text(
|
|
json.dumps({
|
|
"url": "http://openviking-two.test",
|
|
"api_key": "key-two",
|
|
"agent_id": "agent-two",
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
settings = openviking_module._resolve_connection_settings(provider_config)
|
|
|
|
assert settings == {
|
|
"endpoint": "http://openviking-two.test",
|
|
"api_key": "key-two",
|
|
"account": "",
|
|
"user": "",
|
|
"agent": "agent-two",
|
|
}
|
|
|
|
|
|
def test_linked_ovcli_without_url_falls_through_to_dashboard_endpoint(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
ovcli_path = tmp_path / "ovcli.conf"
|
|
ovcli_path.write_text(json.dumps({"api_key": "linked-key"}), encoding="utf-8")
|
|
|
|
settings = openviking_module._resolve_connection_settings({
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(ovcli_path),
|
|
"endpoint": "http://saved.test:1933",
|
|
})
|
|
|
|
assert settings["endpoint"] == "http://saved.test:1933"
|
|
assert settings["api_key"] == "linked-key"
|
|
|
|
|
|
def test_profile_discovery_warns_when_skipping_unsafe_ovcli_endpoint(tmp_path, caplog):
|
|
profile_path = tmp_path / "ovcli.conf.blocked"
|
|
profile_path.write_text(
|
|
json.dumps({"url": "http://169.254.169.254/latest/meta-data"}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with caplog.at_level("WARNING", logger=openviking_module.__name__):
|
|
assert (
|
|
openviking_module._load_profile(
|
|
profile_path,
|
|
source="saved",
|
|
name="blocked",
|
|
)
|
|
is None
|
|
)
|
|
|
|
assert "Skipping invalid OpenViking CLI config" in caplog.text
|
|
assert str(profile_path) in caplog.text
|
|
|
|
|
|
def test_connection_values_omit_stale_identity_for_user_key_with_root_key():
|
|
values = openviking_module._connection_values_from_ovcli({
|
|
"url": "https://openviking.example",
|
|
"api_key": "user-key",
|
|
"root_api_key": "root-key",
|
|
"account": "stale-account",
|
|
"user": "stale-user",
|
|
})
|
|
|
|
assert values["api_key"] == "user-key"
|
|
assert values["account"] == ""
|
|
assert values["user"] == ""
|
|
|
|
|
|
def test_link_ovcli_profile_removes_stale_inline_config(tmp_path):
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text("OPENVIKING_ENDPOINT=http://old.test\nOTHER_KEY=keep\n", encoding="utf-8")
|
|
config = {"memory": {}}
|
|
provider_config = {
|
|
"use_ovcli_config": False,
|
|
"endpoint": "http://stale.test",
|
|
"api_key": "stale-key",
|
|
"account": "default",
|
|
"user": "default",
|
|
"agent": "stale-agent",
|
|
"api_key_type": "root",
|
|
}
|
|
ovcli_path = tmp_path / "ovcli.conf.VPS_ROOT"
|
|
|
|
openviking_module._link_ovcli_profile(
|
|
config=config,
|
|
provider_config=provider_config,
|
|
env_path=env_path,
|
|
ovcli_path=ovcli_path,
|
|
)
|
|
|
|
assert config["memory"]["openviking"] == {
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(ovcli_path),
|
|
}
|
|
assert "OPENVIKING_ENDPOINT" not in env_path.read_text(encoding="utf-8")
|
|
assert "OTHER_KEY=keep" in env_path.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.parametrize("peer_key", [None, "actor_peer_id", "agent_id"])
|
|
def test_post_setup_existing_profile_picker_validates_and_links_saved_profile(
|
|
tmp_path, monkeypatch, peer_key,
|
|
):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
env_path = hermes_home / ".env"
|
|
env_path.write_text("OPENVIKING_ENDPOINT=http://old.test\nOTHER_KEY=keep\n", encoding="utf-8")
|
|
openviking_home = tmp_path / ".openviking"
|
|
openviking_home.mkdir()
|
|
active_path = openviking_home / "ovcli.conf"
|
|
saved_path = openviking_home / "ovcli.conf.VPS"
|
|
active_path.write_text(json.dumps({"url": "http://active.test"}), encoding="utf-8")
|
|
saved_values = {"url": "https://vps.example", "api_key": "user-key"}
|
|
if peer_key:
|
|
saved_values[peer_key] = "existing-peer"
|
|
saved_path.write_text(json.dumps(saved_values), encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path))
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
validate_calls = []
|
|
|
|
def validate_values(values, *, require_api_key=False):
|
|
validate_calls.append(dict(values))
|
|
return True, "", "user"
|
|
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_setup_values",
|
|
validate_values,
|
|
raising=False,
|
|
)
|
|
choices = iter([0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
assert validate_calls == [{
|
|
"endpoint": "https://vps.example",
|
|
"api_key": "user-key",
|
|
"root_api_key": "",
|
|
"account": "",
|
|
"user": "",
|
|
"agent": "existing-peer" if peer_key else "",
|
|
}]
|
|
assert config["memory"]["provider"] == "openviking"
|
|
assert config["memory"]["openviking"] == {
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(saved_path),
|
|
}
|
|
env_text = env_path.read_text(encoding="utf-8")
|
|
assert "OPENVIKING_" not in env_text
|
|
assert "OTHER_KEY=keep" in env_text
|
|
settings = openviking_module._resolve_connection_settings(config["memory"]["openviking"])
|
|
assert settings["agent"] == ("existing-peer" if peer_key else "")
|
|
assert json.loads(saved_path.read_text(encoding="utf-8")) == saved_values
|
|
|
|
|
|
def test_local_setup_recommends_user_api_key_before_unauthenticated_mode(monkeypatch):
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_reachability",
|
|
lambda endpoint: (True, ""),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_setup_values",
|
|
lambda values, *, require_api_key=False: (True, "", "user"),
|
|
)
|
|
credential_menu = {}
|
|
|
|
def select(title, options, *, default=0, cancel_returns=None):
|
|
assert title == " OpenViking credential"
|
|
credential_menu["options"] = options
|
|
credential_menu["default"] = default
|
|
return 0
|
|
|
|
def prompt(label, default=None, secret=False):
|
|
if label == "OpenViking server URL":
|
|
return default
|
|
if label == "OpenViking user API key":
|
|
assert secret is True
|
|
return "user-key"
|
|
raise AssertionError(f"Unexpected prompt: {label}")
|
|
|
|
values = openviking_module._prompt_manual_connection_values(
|
|
prompt,
|
|
select,
|
|
-1,
|
|
)
|
|
|
|
assert [label for label, _description in credential_menu["options"]] == [
|
|
"User API key",
|
|
"Root API key",
|
|
"No API key",
|
|
]
|
|
assert credential_menu["default"] == 0
|
|
assert values["api_key"] == "user-key"
|
|
assert values["api_key_type"] == "user"
|
|
|
|
|
|
def test_start_local_openviking_server_uses_endpoint_host_and_port(monkeypatch):
|
|
popen_calls = []
|
|
|
|
def fake_popen(args, **kwargs):
|
|
popen_calls.append((args, kwargs))
|
|
return object()
|
|
|
|
monkeypatch.setattr(openviking_module, "_local_openviking_port_is_open", lambda host, port: False)
|
|
monkeypatch.setattr(openviking_module.shutil, "which", lambda name: "/usr/local/bin/openviking-server")
|
|
monkeypatch.setattr(openviking_module.subprocess, "Popen", fake_popen)
|
|
|
|
state, message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934")
|
|
|
|
assert state == openviking_module._LOCAL_SERVER_STARTED
|
|
assert "127.0.0.1:1934" in message
|
|
args, kwargs = popen_calls[0]
|
|
assert args == ["/usr/local/bin/openviking-server", "--host", "127.0.0.1", "--port", "1934"]
|
|
assert kwargs["start_new_session"] is True
|
|
|
|
|
|
def test_start_local_openviking_server_strips_pythonpath_from_child_env(monkeypatch):
|
|
"""The spawned server must not inherit Hermes's PYTHONPATH (#78153).
|
|
|
|
Inheriting it makes openviking-server import packages from the Hermes
|
|
venv instead of its own, and on Windows locks Hermes venv DLLs so the
|
|
venv cannot be rebuilt during `hermes update`.
|
|
"""
|
|
popen_calls = []
|
|
|
|
def fake_popen(args, **kwargs):
|
|
popen_calls.append((args, kwargs))
|
|
return object()
|
|
|
|
monkeypatch.setattr(openviking_module, "_local_openviking_port_is_open", lambda host, port: False)
|
|
monkeypatch.setattr(openviking_module.shutil, "which", lambda name: "/usr/local/bin/openviking-server")
|
|
monkeypatch.setattr(openviking_module.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setenv("PYTHONPATH", "/opt/hermes/.venv/Lib/site-packages")
|
|
monkeypatch.setenv("HERMES_PROFILE", "test-profile")
|
|
|
|
state, _message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934")
|
|
|
|
assert state == openviking_module._LOCAL_SERVER_STARTED
|
|
_, kwargs = popen_calls[0]
|
|
child_env = kwargs["env"]
|
|
assert child_env is not None
|
|
assert "PYTHONPATH" not in child_env
|
|
assert child_env.get("HERMES_PROFILE") == "test-profile"
|
|
|
|
|
|
def test_start_local_openviking_server_does_not_spawn_when_port_already_open(monkeypatch):
|
|
"""A live listener means a second server would just die on DataDirectoryLocked."""
|
|
probed = []
|
|
|
|
def fake_probe(host, port):
|
|
probed.append((host, port))
|
|
return True
|
|
|
|
monkeypatch.setattr(openviking_module, "_local_openviking_port_is_open", fake_probe)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_describe_local_port_listener",
|
|
lambda host, port: "python-test-server (PID 4242)",
|
|
)
|
|
monkeypatch.setattr(openviking_module.shutil, "which", lambda name: "/usr/local/bin/openviking-server")
|
|
monkeypatch.setattr(
|
|
openviking_module.subprocess,
|
|
"Popen",
|
|
MagicMock(side_effect=AssertionError("must not spawn while a server is already listening")),
|
|
)
|
|
|
|
state, message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934")
|
|
|
|
assert state == openviking_module._LOCAL_SERVER_OCCUPIED
|
|
assert "python-test-server (PID 4242)" in message
|
|
assert "not passed OpenViking's /health check" in message
|
|
assert "already running" not in message
|
|
assert probed == [("127.0.0.1", 1934)]
|
|
|
|
|
|
def test_start_local_openviking_server_reports_occupied_port_without_cli_on_path(monkeypatch):
|
|
"""The port probe outranks PATH but never claims the listener is OpenViking."""
|
|
monkeypatch.setattr(openviking_module, "_local_openviking_port_is_open", lambda host, port: True)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_describe_local_port_listener",
|
|
lambda host, port: "an unidentified process",
|
|
)
|
|
monkeypatch.setattr(openviking_module.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(
|
|
openviking_module.subprocess,
|
|
"Popen",
|
|
MagicMock(side_effect=AssertionError("must not spawn")),
|
|
)
|
|
|
|
state, message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934")
|
|
|
|
assert state == openviking_module._LOCAL_SERVER_OCCUPIED
|
|
assert "unidentified process" in message
|
|
|
|
|
|
def test_start_local_openviking_server_rejects_unparseable_url_before_probing(monkeypatch):
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_local_openviking_port_is_open",
|
|
MagicMock(side_effect=AssertionError("must not probe an unparseable endpoint")),
|
|
)
|
|
|
|
state, message = openviking_module._start_local_openviking_server("http://127.0.0.1:not-a-port")
|
|
|
|
assert state == openviking_module._LOCAL_SERVER_FAILED
|
|
assert "Could not parse local OpenViking URL" in message
|
|
|
|
|
|
def test_local_openviking_port_is_open_detects_listener_and_closed_port():
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
|
listener.bind(("127.0.0.1", 0))
|
|
listener.listen(1)
|
|
_host, port = listener.getsockname()
|
|
assert openviking_module._local_openviking_port_is_open("127.0.0.1", port) is True
|
|
|
|
# Socket closed: the same port no longer accepts connections.
|
|
assert openviking_module._local_openviking_port_is_open("127.0.0.1", port) is False
|
|
|
|
|
|
def test_describe_local_port_listener_reports_process(monkeypatch):
|
|
import psutil
|
|
|
|
connection = SimpleNamespace(
|
|
status=psutil.CONN_LISTEN,
|
|
laddr=SimpleNamespace(ip="0.0.0.0", port=1934),
|
|
pid=4242,
|
|
)
|
|
monkeypatch.setattr(psutil, "net_connections", lambda *, kind: [connection])
|
|
monkeypatch.setattr(
|
|
psutil,
|
|
"Process",
|
|
lambda pid: SimpleNamespace(name=lambda: "postgres"),
|
|
)
|
|
|
|
assert openviking_module._describe_local_port_listener("127.0.0.1", 1934) == (
|
|
"postgres (PID 4242)"
|
|
)
|
|
|
|
|
|
def test_runtime_reports_occupied_port_and_does_not_wait_or_spawn(monkeypatch):
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: (
|
|
openviking_module._LOCAL_SERVER_OCCUPIED,
|
|
"Port 127.0.0.1:1934 is occupied by postgres (PID 99).",
|
|
),
|
|
)
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._endpoint = "http://127.0.0.1:1934"
|
|
provider._start_runtime_openviking_waiter = MagicMock()
|
|
warnings = []
|
|
|
|
provider._handle_runtime_openviking_unreachable(warning_callback=warnings.append)
|
|
|
|
provider._start_runtime_openviking_waiter.assert_not_called()
|
|
assert provider._client is None
|
|
assert len(warnings) == 1
|
|
assert "postgres (PID 99)" in warnings[0]
|
|
assert "temporarily unavailable" in warnings[0]
|
|
|
|
|
|
def test_https_local_endpoint_is_not_runtime_autostart_eligible(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://localhost:1934")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://localhost:1934"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
MagicMock(side_effect=AssertionError("https localhost endpoint should not auto-start")),
|
|
)
|
|
|
|
warnings = []
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1", platform="cli", warning_callback=warnings.append)
|
|
|
|
assert provider._client is None
|
|
assert warnings == [
|
|
"Remote OpenViking server at https://localhost:1934 is not reachable. "
|
|
"OpenViking memory is temporarily unavailable; Hermes will retry on a later access or when "
|
|
"the config changes. "
|
|
"Check the configured endpoint and network connectivity."
|
|
]
|
|
|
|
|
|
def test_runtime_does_not_autostart_when_local_server_reports_unhealthy(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://localhost:1934"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
def health_payload(self):
|
|
return {"healthy": False}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
MagicMock(side_effect=AssertionError("responding unhealthy server should not auto-start another process")),
|
|
)
|
|
|
|
warnings = []
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1", platform="cli", warning_callback=warnings.append)
|
|
|
|
assert provider._client is None
|
|
assert warnings == [
|
|
"Service at http://localhost:1934 responded but reported unhealthy OpenViking status. "
|
|
"OpenViking memory is temporarily unavailable; Hermes will retry on a later access "
|
|
"or when the config changes."
|
|
]
|
|
|
|
|
|
def test_handle_unreachable_endpoint_waits_long_enough_after_autostart(monkeypatch, capsys):
|
|
wait_calls = []
|
|
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: (
|
|
openviking_module._LOCAL_SERVER_STARTED,
|
|
"Started openviking-server on 127.0.0.1:1934 in the background.",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
lambda endpoint, *, timeout_seconds=0: wait_calls.append((endpoint, timeout_seconds)) or True,
|
|
)
|
|
|
|
result = openviking_module._handle_unreachable_endpoint(
|
|
"http://127.0.0.1:1934",
|
|
"OpenViking server is not reachable.",
|
|
lambda *args, **kwargs: 0,
|
|
-1,
|
|
)
|
|
|
|
assert result is True
|
|
assert wait_calls == [("http://127.0.0.1:1934", 60.0)]
|
|
output = capsys.readouterr().out
|
|
assert "Waiting for OpenViking server to become reachable..." in output
|
|
|
|
|
|
def test_initialize_autostarts_local_openviking_in_background_when_runtime_health_fails(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://127.0.0.1:1934")
|
|
health_calls = []
|
|
start_calls = []
|
|
waiter_calls = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://127.0.0.1:1934"
|
|
|
|
def health(self):
|
|
health_calls.append("health")
|
|
return False
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: start_calls.append(endpoint)
|
|
or (openviking_module._LOCAL_SERVER_STARTED, "started"),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
MagicMock(side_effect=AssertionError("runtime init should not wait synchronously")),
|
|
)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
monkeypatch.setattr(
|
|
provider,
|
|
"_start_runtime_openviking_waiter",
|
|
lambda **kwargs: waiter_calls.append(kwargs),
|
|
raising=False,
|
|
)
|
|
statuses = []
|
|
provider.initialize("session-1", platform="cli", status_callback=statuses.append)
|
|
|
|
assert provider._client is None
|
|
assert health_calls == ["health"]
|
|
assert start_calls == ["http://127.0.0.1:1934"]
|
|
assert len(waiter_calls) == 1
|
|
assert waiter_calls[0]["status_callback"] == statuses.append
|
|
assert any("starting in the background" in message for message in statuses)
|
|
|
|
|
|
def test_tool_search_sorts_by_raw_score_across_buckets():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.post.return_value = {
|
|
"result": {
|
|
"memories": [
|
|
{"uri": "viking://memories/1", "score": 0.9003, "abstract": "memory result"},
|
|
],
|
|
"resources": [
|
|
{"uri": "viking://resources/1", "score": 0.9004, "abstract": "resource result"},
|
|
],
|
|
"skills": [
|
|
{"uri": "viking://skills/1", "score": 0.8999, "abstract": "skill result"},
|
|
],
|
|
"total": 3,
|
|
}
|
|
}
|
|
|
|
result = json.loads(provider._tool_search({"query": "ranking"}))
|
|
|
|
assert [entry["uri"] for entry in result["results"]] == [
|
|
"viking://resources/1",
|
|
"viking://memories/1",
|
|
"viking://skills/1",
|
|
]
|
|
assert [entry["score"] for entry in result["results"]] == [0.9, 0.9, 0.9]
|
|
assert result["total"] == 3
|
|
|
|
|
|
def test_tool_add_resource_rejects_hermes_credential_file_upload(tmp_path, monkeypatch):
|
|
import agent.file_safety as fs
|
|
|
|
hermes_home = tmp_path / "hermes_home"
|
|
hermes_home.mkdir()
|
|
auth_json = hermes_home / "auth.json"
|
|
auth_json.write_text('{"OPENROUTER_API_KEY":"sk-test-secret"}', encoding="utf-8")
|
|
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
|
|
result = json.loads(provider._tool_add_resource({"url": str(auth_json)}))
|
|
|
|
assert "error" in result
|
|
assert "credential store" in result["error"]
|
|
provider._client.upload_temp_file.assert_not_called()
|
|
provider._client.post.assert_not_called()
|
|
|
|
|
|
def test_get_tool_schemas_omits_profile_and_keeps_narrow_forget_tools():
|
|
provider = OpenVikingMemoryProvider()
|
|
|
|
names = [schema["name"] for schema in provider.get_tool_schemas()]
|
|
|
|
assert "viking_profile" not in names
|
|
assert "viking_forget" in names
|
|
|
|
|
|
def test_viking_client_delete_uses_identity_headers(monkeypatch):
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="acct",
|
|
user="alice",
|
|
agent="hermes",
|
|
)
|
|
captured = {}
|
|
|
|
def capture_delete(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["kwargs"] = kwargs
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "result": {"uri": "viking://~/memories/x.md"}},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "delete", capture_delete)
|
|
|
|
assert client.delete("/api/v1/fs", params={"uri": "viking://~/memories/x.md"}) == {
|
|
"status": "ok",
|
|
"result": {"uri": "viking://~/memories/x.md"},
|
|
}
|
|
assert captured["url"] == "https://example.com/api/v1/fs"
|
|
assert captured["kwargs"]["params"] == {"uri": "viking://~/memories/x.md"}
|
|
assert captured["kwargs"]["headers"]["Authorization"] == "Bearer test-key"
|
|
assert captured["kwargs"]["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
|
|
assert captured["kwargs"]["headers"]["User-Agent"] == _EXPECTED_USER_AGENT
|
|
|
|
|
|
def test_viking_client_upload_uses_user_agent_without_json_content_type(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="acct",
|
|
user="alice",
|
|
agent="hermes",
|
|
)
|
|
upload = tmp_path / "notes.txt"
|
|
upload.write_text("notes", encoding="utf-8")
|
|
captured = {}
|
|
|
|
def capture_post(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["kwargs"] = kwargs
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"result": {"temp_file_id": "temp-1"}},
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "post", capture_post)
|
|
|
|
assert client.upload_temp_file(upload) == "temp-1"
|
|
assert captured["url"] == "https://example.com/api/v1/resources/temp_upload"
|
|
headers = captured["kwargs"]["headers"]
|
|
assert headers["User-Agent"] == _EXPECTED_USER_AGENT
|
|
assert "Content-Type" not in headers
|
|
|
|
|
|
def test_openviking_identity_probes_are_anonymous_before_authenticated_requests(monkeypatch):
|
|
calls = []
|
|
|
|
def response(payload):
|
|
return SimpleNamespace(status_code=200, text="", json=lambda: payload)
|
|
|
|
def fake_get(url, **kwargs):
|
|
calls.append((url, kwargs["headers"]))
|
|
if url.endswith("/health"):
|
|
return response({"status": "ok"})
|
|
if url.endswith("/openapi.json"):
|
|
return response({"info": {"title": "OpenViking API"}})
|
|
if url.endswith("/api/v1/system/status"):
|
|
return response({"status": "ok"})
|
|
if url.endswith("/api/v1/admin/accounts"):
|
|
return response({"status": "ok", "result": []})
|
|
raise AssertionError(f"unexpected request: {url}")
|
|
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_get_httpx",
|
|
lambda: SimpleNamespace(get=fake_get),
|
|
)
|
|
|
|
valid, message, role = openviking_module._validate_openviking_setup_values({
|
|
"endpoint": "https://openviking.example",
|
|
"api_key": "secret-key",
|
|
"account": "acct",
|
|
"user": "alice",
|
|
"agent": "hermes",
|
|
})
|
|
|
|
assert (valid, message, role) == (True, "", "root")
|
|
assert [url.removeprefix("https://openviking.example") for url, _headers in calls] == [
|
|
"/health",
|
|
"/openapi.json",
|
|
"/api/v1/system/status",
|
|
"/api/v1/admin/accounts",
|
|
]
|
|
expected_anonymous_headers = {
|
|
"Accept": "application/json",
|
|
}
|
|
assert calls[0][1] == expected_anonymous_headers
|
|
assert calls[1][1] == expected_anonymous_headers
|
|
for _url, headers in calls[2:]:
|
|
assert headers["X-API-Key"] == "secret-key"
|
|
assert headers["Authorization"] == "Bearer secret-key"
|
|
|
|
|
|
def test_repeated_openviking_health_probes_never_send_credentials_or_tenant_headers(
|
|
monkeypatch,
|
|
):
|
|
captured_headers = []
|
|
client = _VikingClient(
|
|
"https://openviking.example",
|
|
api_key="secret-key",
|
|
account="acct",
|
|
user="alice",
|
|
agent="hermes",
|
|
)
|
|
|
|
def fake_get(_url, **kwargs):
|
|
captured_headers.append(kwargs["headers"])
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "healthy": True, "version": "0.2.10"},
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", fake_get)
|
|
|
|
assert client.health() is True
|
|
assert client.health() is True
|
|
assert captured_headers == [
|
|
{"Accept": "application/json"},
|
|
{"Accept": "application/json"},
|
|
]
|
|
|
|
|
|
def test_cloud_health_retries_with_api_key_after_anonymous_auth_error(monkeypatch):
|
|
"""Hosted OpenViking may require auth on GET /health (#78410)."""
|
|
calls = []
|
|
client = _VikingClient(
|
|
"https://api.vikingdb.cn-beijing.volces.com/openviking",
|
|
api_key="account.user.0123456789abcdef0123456789abcdef",
|
|
agent="hermes",
|
|
)
|
|
modern = {"status": "ok", "healthy": True, "version": "0.3.0"}
|
|
|
|
def fake_get(url, **kwargs):
|
|
headers = kwargs["headers"]
|
|
calls.append(dict(headers))
|
|
if "Authorization" not in headers:
|
|
return SimpleNamespace(
|
|
status_code=401,
|
|
text='{"error":{"code":"AuthenticationError","message":"The API key in the request is missing or invalid."}}',
|
|
json=lambda: {
|
|
"error": {
|
|
"code": "AuthenticationError",
|
|
"message": "The API key in the request is missing or invalid.",
|
|
}
|
|
},
|
|
)
|
|
return SimpleNamespace(status_code=200, text="", json=lambda: modern)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", fake_get)
|
|
|
|
payload = client.health_payload()
|
|
assert payload == modern
|
|
assert client.health() is True
|
|
assert calls[0] == {
|
|
"Accept": "application/json",
|
|
}
|
|
assert "Authorization" in calls[1]
|
|
assert calls[1]["Authorization"].startswith("Bearer account.user.")
|
|
assert "X-API-Key" in calls[1]
|
|
# No tenant headers on health.
|
|
assert "X-OpenViking-Account" not in calls[1]
|
|
assert "X-OpenViking-User" not in calls[1]
|
|
|
|
|
|
def test_cloud_health_does_not_send_key_without_api_key(monkeypatch):
|
|
client = _VikingClient(
|
|
"https://api.vikingdb.cn-beijing.volces.com/openviking",
|
|
api_key="",
|
|
agent="hermes",
|
|
)
|
|
calls = []
|
|
|
|
def fake_get(url, **kwargs):
|
|
calls.append(kwargs["headers"])
|
|
return SimpleNamespace(
|
|
status_code=401,
|
|
text="AuthenticationError",
|
|
json=lambda: {
|
|
"error": {
|
|
"code": "AuthenticationError",
|
|
"message": "The API key in the request is missing or invalid.",
|
|
}
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", fake_get)
|
|
|
|
with pytest.raises(openviking_module._OpenVikingHTTPError):
|
|
client.health_payload()
|
|
assert calls == [{"Accept": "application/json"}]
|
|
|
|
|
|
def test_health_non_auth_errors_do_not_retry_with_credentials(monkeypatch):
|
|
client = _VikingClient(
|
|
"https://openviking.example",
|
|
api_key="secret-key",
|
|
agent="hermes",
|
|
)
|
|
calls = []
|
|
|
|
def fake_get(url, **kwargs):
|
|
calls.append(kwargs["headers"])
|
|
return SimpleNamespace(
|
|
status_code=503,
|
|
text="unavailable",
|
|
json=lambda: {"error": {"code": "UNAVAILABLE", "message": "down"}},
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", fake_get)
|
|
|
|
with pytest.raises(openviking_module._OpenVikingHTTPError):
|
|
client.health_payload()
|
|
assert calls == [{"Accept": "application/json"}]
|
|
|
|
|
|
def test_modern_openviking_identity_does_not_probe_openapi():
|
|
client = MagicMock()
|
|
client.health_payload.return_value = {
|
|
"status": "ok",
|
|
"healthy": True,
|
|
"version": "0.2.10",
|
|
}
|
|
|
|
state, health = openviking_module._probe_openviking_identity(client)
|
|
|
|
assert state == "modern"
|
|
assert health["version"] == "0.2.10"
|
|
client.openapi_payload.assert_not_called()
|
|
|
|
|
|
def test_legacy_health_requires_openviking_openapi_identity_before_auth(monkeypatch):
|
|
events = []
|
|
|
|
class ForeignServiceClient:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
events.append("health")
|
|
return {"status": "ok"}
|
|
|
|
def openapi_payload(self):
|
|
events.append("openapi")
|
|
return {"info": {"title": "Unrelated Service"}}
|
|
|
|
def validate_auth(self):
|
|
raise AssertionError("credentials must not be sent before identity is verified")
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", ForeignServiceClient)
|
|
|
|
valid, message, role = openviking_module._validate_openviking_setup_values({
|
|
"endpoint": "https://foreign.example",
|
|
"api_key": "secret-key",
|
|
})
|
|
|
|
assert valid is False
|
|
assert role is None
|
|
assert "0.2.6 or earlier" in message
|
|
assert "0.2.10 or newer" in message
|
|
assert events == ["health", "openapi"]
|
|
|
|
|
|
def test_verified_legacy_openviking_is_healthy_for_reachability_and_runtime(monkeypatch):
|
|
events = []
|
|
|
|
class LegacyOpenVikingClient:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
events.append("health")
|
|
return {"status": "ok"}
|
|
|
|
def openapi_payload(self):
|
|
events.append("openapi")
|
|
return {"info": {"title": "OpenViking API"}}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", LegacyOpenVikingClient)
|
|
|
|
reachable, message = openviking_module._validate_openviking_reachability(
|
|
"https://legacy.example"
|
|
)
|
|
runtime_state, runtime_message = openviking_module._classify_runtime_openviking_health(
|
|
LegacyOpenVikingClient(),
|
|
"https://legacy.example",
|
|
)
|
|
|
|
assert (reachable, message) == (True, "")
|
|
assert (runtime_state, runtime_message) == ("healthy", "")
|
|
assert events == ["health", "openapi", "health", "openapi"]
|
|
|
|
|
|
def test_validate_openviking_reachability_uses_health_only(monkeypatch):
|
|
events = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://openviking.example"
|
|
assert api_key == ""
|
|
|
|
def health(self):
|
|
events.append("health")
|
|
return True
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message = openviking_module._validate_openviking_reachability(
|
|
"https://openviking.example"
|
|
)
|
|
|
|
assert ok is True
|
|
assert message == ""
|
|
assert events == ["health"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_session_switch — flush + commit + rotate behavior (hermes-agent#28296)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_provider_with_session(session_id: str, turn_count: int):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._session_id = session_id
|
|
provider._turn_count = turn_count
|
|
return provider
|
|
|
|
|
|
def test_on_session_switch_commits_old_session_and_rotates_id():
|
|
provider = _make_provider_with_session("old-sid", turn_count=3)
|
|
|
|
provider.on_session_switch("new-sid", parent_session_id="old-sid")
|
|
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_sync_turn_captures_session_id_before_worker_runs():
|
|
"""Worker must use the session id snapshotted at sync_turn() call time, not
|
|
re-read self._session_id later — otherwise a delayed worker can write the
|
|
previous turn's messages into the rotated-in NEW session."""
|
|
import threading
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "old-sid"
|
|
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
captured_paths = []
|
|
captured_payloads = []
|
|
|
|
def fake_post(path, payload=None, **kwargs):
|
|
started.set()
|
|
release.wait(timeout=2.0)
|
|
captured_paths.append(path)
|
|
captured_payloads.append(payload)
|
|
return {}
|
|
|
|
# Patch _VikingClient inside the worker by stubbing post on a client
|
|
# the constructor will produce. Easiest path: monkeypatch the class.
|
|
real_client_cls = _VikingClient
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
return fake_post(path, payload, **kwargs)
|
|
|
|
import plugins.memory.openviking as _mod
|
|
_mod._VikingClient = StubClient
|
|
try:
|
|
provider.sync_turn("u", "a")
|
|
# Wait until the worker is parked inside the first post call.
|
|
assert started.wait(timeout=2.0), "worker never entered post()"
|
|
# Rotate the provider's session id while the worker is mid-flight.
|
|
provider._session_id = "new-sid"
|
|
release.set()
|
|
for t in list(provider._inflight_writers.get("old-sid", set())):
|
|
t.join(timeout=2.0)
|
|
finally:
|
|
_mod._VikingClient = real_client_cls
|
|
|
|
# The whole turn must target the OLD session id as a single ordered batch.
|
|
assert captured_paths == ["/api/v1/sessions/old-sid/messages/batch"]
|
|
assert captured_payloads == [{
|
|
"messages": [
|
|
{"role": "user", "parts": [{"type": "text", "text": "u"}]},
|
|
{"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
|
|
]
|
|
}]
|
|
|
|
|
|
def _long_structured_turn(assistant_count=204):
|
|
return [
|
|
{"role": "user", "content": "u"},
|
|
*[
|
|
{"role": "assistant", "content": f"assistant-{index}"}
|
|
for index in range(assistant_count)
|
|
],
|
|
]
|
|
|
|
|
|
def test_end_then_switch_does_not_double_commit():
|
|
"""Mirrors the /new and compression call order: commit_memory_session
|
|
(→ on_session_end) immediately followed by on_session_switch. The switch
|
|
must NOT issue a second commit on the same session id."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
|
|
provider.on_session_end([])
|
|
provider.on_session_switch("new-sid", parent_session_id="old-sid")
|
|
|
|
# Exactly one commit call, on the OLD session, fired by on_session_end.
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_session_needs_commit_guard_wins_over_stale_turn_count():
|
|
"""Regression for hermes-agent#28296 review (M3): once a session is marked
|
|
committed, _session_needs_commit must return False even if turn_count is
|
|
still positive. A racing sync_turn can re-increment _turn_count after the
|
|
commit+reset; without the guard ordering, a follow-up finalizer would
|
|
double-commit the same session. The committed-guard must be checked BEFORE
|
|
the turn_count>0 shortcut."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=5)
|
|
provider._mark_session_committed("old-sid")
|
|
|
|
# turn_count is a (stale) 5 but the session is already committed.
|
|
assert provider._session_needs_commit("old-sid", 5) is False
|
|
# An uncommitted session with turns still needs a commit.
|
|
assert provider._session_needs_commit("fresh-sid", 5) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hung-writer protection: the sync worker can outlive the bounded join
|
|
# because each OpenViking POST has _TIMEOUT=30s and there are two per turn.
|
|
# Committing while late writes are still in flight would orphan them past
|
|
# the commit boundary — they would never be extracted.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _HungThread:
|
|
"""Thread stand-in that stays alive across joins."""
|
|
|
|
def is_alive(self):
|
|
return True
|
|
|
|
def join(self, timeout=None):
|
|
# Pretend the join timed out — worker still running.
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orphaned-writer hazard: commit must wait for ALL writers for the session,
|
|
# not just the latest tracked one. sync_turn's bounded rate-limit can drop a
|
|
# still-alive previous worker — that dropped writer keeps POSTing under the
|
|
# old sid and would otherwise land its writes past the commit boundary.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX advisory locks")
|
|
@pytest.mark.parametrize("owner_run_id", ["dead-owner", ""])
|
|
def test_concurrent_providers_claim_unlocked_pending_owner_once(
|
|
tmp_path,
|
|
monkeypatch,
|
|
owner_run_id,
|
|
):
|
|
"""Only one provider may recover a missing or legacy owner lock."""
|
|
import threading
|
|
|
|
pytest.importorskip("fcntl")
|
|
_clear_openviking_env(monkeypatch)
|
|
|
|
pending_dir = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR
|
|
pending_dir.mkdir(parents=True)
|
|
marker = pending_dir / "old-sid.json"
|
|
marker.write_text(
|
|
json.dumps({"session_id": "old-sid", "owner_run_id": owner_run_id}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
posts = []
|
|
posts_lock = threading.Lock()
|
|
commit_started = threading.Event()
|
|
release_commit = threading.Event()
|
|
|
|
class StubClient:
|
|
def post(self, path, payload=None, **kwargs):
|
|
with posts_lock:
|
|
posts.append((path, payload))
|
|
commit_started.set()
|
|
release_commit.wait(timeout=5.0)
|
|
return {}
|
|
|
|
providers = [OpenVikingMemoryProvider(), OpenVikingMemoryProvider()]
|
|
scan_barrier = threading.Barrier(len(providers))
|
|
for provider in providers:
|
|
provider._client = StubClient()
|
|
provider._hermes_home = str(tmp_path)
|
|
pending_sessions = provider._pending_sessions
|
|
|
|
def _scan_together(scan=pending_sessions):
|
|
sessions = scan()
|
|
scan_barrier.wait(timeout=2.0)
|
|
return sessions
|
|
|
|
provider._pending_sessions = _scan_together
|
|
|
|
recovery_threads = [
|
|
threading.Thread(target=provider._recover_pending_sessions)
|
|
for provider in providers
|
|
]
|
|
for thread in recovery_threads:
|
|
thread.start()
|
|
for thread in recovery_threads:
|
|
thread.join(timeout=2.0)
|
|
assert not thread.is_alive()
|
|
|
|
assert commit_started.wait(timeout=2.0), "recovery commit did not start"
|
|
release_commit.set()
|
|
assert all(provider._drain_finalizers(timeout=2.0) for provider in providers)
|
|
|
|
assert posts.count((
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_memory_write: explicit memory writes use content/write and stay outside
|
|
# the session transcript/commit boundary.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_shutdown_waits_for_memory_write_worker(monkeypatch):
|
|
import threading
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
|
|
worker_started = threading.Event()
|
|
release_worker = threading.Event()
|
|
worker_finished = threading.Event()
|
|
shutdown_returned = threading.Event()
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
assert path == "/api/v1/content/write"
|
|
worker_started.set()
|
|
release_worker.wait(timeout=2.0)
|
|
worker_finished.set()
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider.on_memory_write("add", "user", "remember this")
|
|
assert worker_started.wait(timeout=2.0), "worker never entered post()"
|
|
|
|
shutdown_thread = threading.Thread(
|
|
target=lambda: (provider.shutdown(), shutdown_returned.set()),
|
|
daemon=True,
|
|
)
|
|
shutdown_thread.start()
|
|
|
|
returned_before_worker_finished = shutdown_returned.wait(timeout=0.1)
|
|
release_worker.set()
|
|
assert shutdown_returned.wait(timeout=2.0), "shutdown did not return after worker finished"
|
|
shutdown_thread.join(timeout=2.0)
|
|
|
|
assert not returned_before_worker_finished
|
|
assert worker_finished.is_set()
|
|
assert provider._memory_write_threads == set()
|
|
|
|
|
|
def test_memory_write_uses_one_connection_for_identity_uri_and_post(monkeypatch):
|
|
import threading
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._agent = "alice-agent"
|
|
provider._ensure_client = lambda: True
|
|
|
|
identity_started = threading.Event()
|
|
release_identity = threading.Event()
|
|
write_finished = threading.Event()
|
|
writes = []
|
|
|
|
class StubClient:
|
|
def __init__(self, user, agent):
|
|
self._user = user
|
|
self._agent = agent
|
|
|
|
def get(self, path, **kwargs):
|
|
assert path == "/api/v1/system/status"
|
|
identity_started.set()
|
|
assert release_identity.wait(timeout=2.0)
|
|
return {"status": "ok", "result": {"user": self._user}}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
writes.append((self._user, self._agent, path, payload))
|
|
write_finished.set()
|
|
return {"status": "ok"}
|
|
|
|
alice = StubClient("alice", "alice-agent")
|
|
bob = StubClient("bob", "bob-agent")
|
|
provider._client = alice
|
|
monkeypatch.setattr(provider, "_new_client", lambda: alice)
|
|
|
|
provider.on_memory_write("add", "user", "remember this")
|
|
assert identity_started.wait(timeout=2.0), "identity probe did not start"
|
|
|
|
# Simulate a profile reload while the write worker is resolving identity.
|
|
provider._client = bob
|
|
provider._agent = "bob-agent"
|
|
release_identity.set()
|
|
|
|
assert write_finished.wait(timeout=2.0), "memory write did not finish"
|
|
for worker in list(provider._memory_write_threads):
|
|
worker.join(timeout=2.0)
|
|
|
|
assert len(writes) == 1
|
|
user, agent, path, payload = writes[0]
|
|
assert (user, agent, path) == ("alice", "alice-agent", "/api/v1/content/write")
|
|
assert payload["uri"].startswith(
|
|
"viking://user/alice/peers/alice-agent/memories/preferences/mem_"
|
|
)
|
|
assert provider._memory_write_threads == set()
|
|
|
|
|
|
def _make_prefetch_provider() -> OpenVikingMemoryProvider:
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
return provider
|
|
|
|
|
|
_SESSION_START_LIST_PARAMS = {
|
|
"output": "agent",
|
|
"recursive": True,
|
|
"abs_limit": 512,
|
|
"node_limit": 512,
|
|
}
|
|
|
|
|
|
def _memory_listing(*entries):
|
|
return list(entries)
|
|
|
|
|
|
def _mock_session_start_reads(
|
|
provider: OpenVikingMemoryProvider,
|
|
responses: dict[tuple[str, str], object],
|
|
):
|
|
calls = []
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
request_params = dict(params or {})
|
|
uri = request_params.get("uri", "")
|
|
calls.append((path, request_params, kwargs.get("timeout")))
|
|
if path == "/api/v1/system/status":
|
|
return {"status": "ok", "result": {"user": "default"}}
|
|
response = responses.get((path, uri), "")
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return {"result": response}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
return calls
|
|
|
|
|
|
def test_session_start_token_estimator_matches_shared_openviking_contract():
|
|
provider = OpenVikingMemoryProvider
|
|
|
|
assert provider._estimate_tokens("abcd") == 1
|
|
assert provider._estimate_tokens("设") == 2
|
|
assert provider._estimate_tokens("设置") == 3
|
|
assert provider._estimate_tokens("设置ab") == 4
|
|
|
|
|
|
def test_prefetch_prepends_session_start_memory_context_once_per_session():
|
|
provider = _make_prefetch_provider()
|
|
calls = _mock_session_start_reads(
|
|
provider,
|
|
{
|
|
("/api/v1/content/read", "viking://user/default/memories/profile.md"): (
|
|
"User prefers concise answers."
|
|
),
|
|
("/api/v1/fs/ls", "viking://user/default/memories/preferences"): _memory_listing(
|
|
{"isDir": True, "rel_path": "owner"},
|
|
{
|
|
"isDir": False,
|
|
"rel_path": "owner/z-last.md",
|
|
"abstract": " Keep replies compact. ",
|
|
},
|
|
{
|
|
"isDir": False,
|
|
"rel_path": "owner/a-first.md",
|
|
"abstract": "Verify source before editing.",
|
|
},
|
|
{"isDir": False, "rel_path": "owner/ignored.txt", "abstract": "ignore"},
|
|
),
|
|
("/api/v1/fs/ls", "viking://user/default/memories/entities"): _memory_listing(
|
|
{
|
|
"isDir": False,
|
|
"rel_path": "people/ada.md",
|
|
"abstract": "Ada Lovelace is a collaborator.",
|
|
},
|
|
),
|
|
},
|
|
)
|
|
provider._search_prefetch_context = MagicMock(return_value="- [events]\n recalled context")
|
|
|
|
first = provider.prefetch("What should we recall?", session_id="sid-123")
|
|
second = provider.prefetch("What should we recall?", session_id="sid-123")
|
|
|
|
assert '<user-profile uri="viking://user/default/memories/profile.md">' in first
|
|
assert "User prefers concise answers." in first
|
|
assert "<available-memories>" in first
|
|
assert "viking://user/default/memories/preferences/" in first
|
|
assert "owner/z-last.md — Keep replies compact." in first
|
|
assert first.index("owner/a-first.md") < first.index("owner/z-last.md")
|
|
assert "viking://user/default/memories/entities/" in first
|
|
assert "people/ada.md — Ada Lovelace is a collaborator." in first
|
|
assert "owner/ignored.txt" not in first
|
|
assert "<preferences" not in first
|
|
assert "<entities" not in first
|
|
assert "recalled context" in first
|
|
assert "<user-profile" not in second
|
|
assert "recalled context" in second
|
|
assert [(path, params) for path, params, _timeout in calls] == [
|
|
# The user-space probe runs once, before the first URI is built.
|
|
("/api/v1/system/status", {}),
|
|
("/api/v1/content/read", {"uri": "viking://user/default/memories/profile.md"}),
|
|
(
|
|
"/api/v1/fs/ls",
|
|
{"uri": "viking://user/default/memories/preferences", **_SESSION_START_LIST_PARAMS},
|
|
),
|
|
(
|
|
"/api/v1/fs/ls",
|
|
{"uri": "viking://user/default/memories/entities", **_SESSION_START_LIST_PARAMS},
|
|
),
|
|
]
|
|
assert provider._search_prefetch_context.call_count == 2
|
|
|
|
|
|
def test_session_start_reuses_one_fallback_user_after_status_probe_failure():
|
|
provider = _make_prefetch_provider()
|
|
provider._user = "configured-user"
|
|
provider._client._user = "configured-user"
|
|
provider._search_prefetch_context = MagicMock(return_value="")
|
|
status_calls = 0
|
|
status_timeouts = []
|
|
read_uris = []
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
nonlocal status_calls
|
|
if path == "/api/v1/system/status":
|
|
status_calls += 1
|
|
status_timeouts.append(kwargs.get("timeout"))
|
|
if status_calls == 1:
|
|
raise RuntimeError("temporary status failure")
|
|
return {"status": "ok", "result": {"user": "alice"}}
|
|
|
|
uri = (params or {}).get("uri", "")
|
|
read_uris.append(uri)
|
|
if path == "/api/v1/content/read":
|
|
return {"result": "Configured-user profile."}
|
|
return {"result": []}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
|
|
block = provider.prefetch("What should we recall?", session_id="sid-fallback")
|
|
|
|
assert status_calls == 1
|
|
assert len(status_timeouts) == 1
|
|
assert 0 < status_timeouts[0] <= 3.0
|
|
assert read_uris == [
|
|
"viking://user/configured-user/memories/profile.md",
|
|
"viking://user/configured-user/memories/preferences",
|
|
"viking://user/configured-user/memories/entities",
|
|
]
|
|
assert (
|
|
'<user-profile uri="viking://user/configured-user/memories/profile.md">'
|
|
in block
|
|
)
|
|
|
|
|
|
def test_prefetch_reinjects_after_in_place_compression_same_session():
|
|
provider = _make_prefetch_provider()
|
|
provider._session_id = "sid-123"
|
|
profiles = iter(["Profile before compression.", "Profile after compression."])
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
uri = (params or {}).get("uri", "")
|
|
if path == "/api/v1/system/status":
|
|
return {"status": "ok", "result": {"user": "default"}}
|
|
if uri == "viking://user/default/memories/profile.md":
|
|
return {"result": next(profiles)}
|
|
return {"result": []}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
first = provider.prefetch("hi", session_id="sid-123")
|
|
provider._turn_count = 3
|
|
provider.on_session_switch("sid-123", reason="compression")
|
|
second = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert "Profile before compression." in first
|
|
assert "Profile after compression." in second
|
|
|
|
|
|
def test_queue_prefetch_is_noop_for_openviking_recall(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
constructed_clients = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
constructed_clients.append((a, kw))
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider.queue_prefetch("anything", session_id="sid-123")
|
|
|
|
assert constructed_clients == []
|
|
|
|
|
|
def test_prefetch_sends_contract_safe_memory_context_payload(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_calls = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured_calls.append((path, payload))
|
|
return {"result": {"memories": [], "resources": []}}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider.prefetch("anything")
|
|
|
|
assert captured_calls == [
|
|
(
|
|
"/api/v1/search/find",
|
|
{
|
|
"query": "anything",
|
|
"limit": 24,
|
|
"score_threshold": 0,
|
|
"context_type": "memory",
|
|
},
|
|
)
|
|
]
|
|
payload = captured_calls[0][1]
|
|
assert "top_k" not in payload
|
|
assert "mode" not in payload
|
|
assert "target_uri" not in payload
|
|
|
|
def test_in_place_compression_rearms_commit_guard():
|
|
"""Post-compression turns must still be committable (#74695).
|
|
|
|
``compress_context()`` commits before rewriting the transcript, which
|
|
latches the per-sid guard. In-place mode (the default) keeps the SAME sid,
|
|
so the latch then rejected every later commit for a still-live session —
|
|
the next compression, /new, normal session end and startup recovery all
|
|
silently did nothing, and post-compression turns were never extracted.
|
|
"""
|
|
provider = _make_provider_with_session("sid-123", turn_count=4)
|
|
provider._ensure_client = lambda: True
|
|
|
|
# Compression commits the live session, latching the guard.
|
|
provider._mark_session_committed("sid-123")
|
|
assert provider._session_needs_commit("sid-123", 4) is False
|
|
|
|
# In-place compression: same id in, no rotation.
|
|
provider.on_session_switch("sid-123", reason="compression")
|
|
|
|
# The session is still live, so new turns must be committable again.
|
|
assert provider._has_committed_session("sid-123") is False
|
|
assert provider._turn_count == 0
|
|
assert provider._session_needs_commit("sid-123", 2) is True
|
|
|
|
|
|
def test_rotating_compression_keeps_old_session_latched():
|
|
"""Rotation mode must keep the guard, which dedupes the old id's finalize.
|
|
|
|
With ``compression.in_place: false`` a fresh child id is minted. The old id
|
|
stays committed so its ``_finalize_session_async`` does not double-commit
|
|
what compression already committed — the behavior the guard exists for.
|
|
"""
|
|
provider = _make_provider_with_session("old-sid", turn_count=4)
|
|
provider._ensure_client = lambda: True
|
|
provider._finalize_session_async = MagicMock()
|
|
|
|
provider._mark_session_committed("old-sid")
|
|
provider.on_session_switch("new-sid", reason="compression")
|
|
|
|
assert provider._has_committed_session("old-sid") is True
|
|
assert provider._session_needs_commit("old-sid", 4) is False
|
|
|
|
|
|
def test_undo_rewind_does_not_rearm_commit_guard():
|
|
"""Only compression re-arms; a same-session /undo must not."""
|
|
provider = _make_provider_with_session("sid-123", turn_count=4)
|
|
provider._ensure_client = lambda: True
|
|
|
|
provider._mark_session_committed("sid-123")
|
|
provider.on_session_switch("sid-123", rewound=True)
|
|
|
|
assert provider._has_committed_session("sid-123") is True
|
|
|
|
|
|
def test_in_place_compression_lifecycle_allows_a_later_commit():
|
|
"""End-to-end wiring, not a hand-set latch (#74695).
|
|
|
|
Drives the real sequence a session goes through: commit at the compression
|
|
boundary, same-id ``on_session_switch``, a post-compression turn via
|
|
``sync_turn``, then a later commit. Before the fix the second commit never
|
|
reached the server, so every turn after the first compression was lost.
|
|
"""
|
|
provider = _make_provider_with_session("sid-123", turn_count=3)
|
|
provider._ensure_client = lambda: True
|
|
provider._new_client = lambda: provider._client
|
|
|
|
def _commit_calls():
|
|
return [
|
|
c for c in provider._client.post.call_args_list
|
|
if c.args and str(c.args[0]).endswith("/commit")
|
|
]
|
|
|
|
# 1. Compression commits the live session through the real path.
|
|
provider.on_session_end([{"role": "user", "content": "before"}])
|
|
assert len(_commit_calls()) == 1
|
|
assert provider._has_committed_session("sid-123") is True
|
|
|
|
# 2. In-place compression: same id back in, no rotation.
|
|
provider.on_session_switch("sid-123", reason="compression")
|
|
|
|
# No new turns means no duplicate extraction at an immediate boundary.
|
|
provider.on_session_end([])
|
|
assert len(_commit_calls()) == 1
|
|
|
|
# 3. A genuinely new turn lands on the still-live session.
|
|
provider.sync_turn("after compression", "reply", session_id="sid-123")
|
|
assert provider._drain_writers("sid-123", timeout=5.0)
|
|
assert provider._turn_count > 0
|
|
assert any(
|
|
call.args and str(call.args[0]).endswith("/messages/batch")
|
|
for call in provider._client.post.call_args_list
|
|
)
|
|
|
|
# 4. That turn must still be committable.
|
|
provider.on_session_end([{"role": "user", "content": "after"}])
|
|
assert len(_commit_calls()) == 2, (
|
|
"post-compression turns were never committed: "
|
|
f"{provider._client.post.call_args_list}"
|
|
)
|
|
|
|
def test_resolve_connection_settings_reads_config_yaml_non_secret_fields(monkeypatch):
|
|
"""#68209: non-secret fields saved to config.yaml feed the resolution chain."""
|
|
_clear_openviking_env(monkeypatch)
|
|
provider_config = {
|
|
"endpoint": "http://saved.test:1933",
|
|
"account": "cfg-account",
|
|
"user": "cfg-user",
|
|
"agent": "cfg-agent",
|
|
}
|
|
|
|
settings = openviking_module._resolve_connection_settings(provider_config)
|
|
|
|
assert settings["endpoint"] == "http://saved.test:1933"
|
|
assert settings["account"] == "cfg-account"
|
|
assert settings["user"] == "cfg-user"
|
|
assert settings["agent"] == "cfg-agent"
|
|
|
|
|
|
def test_env_overrides_config_yaml_non_secret_fields(monkeypatch):
|
|
"""env still wins over config.yaml (env -> ovcli -> config.yaml -> default)."""
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://env.test")
|
|
monkeypatch.setenv("OPENVIKING_AGENT", "env-agent")
|
|
|
|
settings = openviking_module._resolve_connection_settings(
|
|
{"endpoint": "http://saved.test", "agent": "cfg-agent"}
|
|
)
|
|
|
|
assert settings["endpoint"] == "http://env.test"
|
|
assert settings["agent"] == "env-agent"
|
|
|
|
|
|
def test_blocked_endpoint_does_not_fall_back_or_construct_client(monkeypatch, tmp_path):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv(
|
|
"OPENVIKING_ENDPOINT",
|
|
"http://169.254.169.254/latest/meta-data/temporary-credential",
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_VikingClient",
|
|
MagicMock(side_effect=AssertionError("blocked endpoint must not construct a client")),
|
|
)
|
|
warnings = []
|
|
provider = OpenVikingMemoryProvider()
|
|
|
|
provider.initialize(
|
|
"session-1",
|
|
hermes_home=str(tmp_path),
|
|
platform="cli",
|
|
warning_callback=warnings.append,
|
|
)
|
|
|
|
assert provider._client is None
|
|
assert provider._endpoint == ""
|
|
assert len(warnings) == 1
|
|
assert "blocked metadata address" in warnings[0]
|
|
assert "temporary-credential" not in warnings[0]
|
|
assert openviking_module._DEFAULT_ENDPOINT not in warnings[0]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"health_payload",
|
|
[
|
|
{"status": "ok", "healthy": True},
|
|
["not", "openviking"],
|
|
],
|
|
)
|
|
def test_runtime_rejects_unrelated_json_health_response(
|
|
monkeypatch, tmp_path, health_payload
|
|
):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934")
|
|
|
|
class UnrelatedJsonService:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return health_payload
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", UnrelatedJsonService)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_local_openviking_port_is_open",
|
|
lambda host, port: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_describe_local_port_listener",
|
|
lambda host, port: "python-http-server (PID 4242)",
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
MagicMock(side_effect=AssertionError("responding non-OpenViking service must not auto-start")),
|
|
)
|
|
warnings = []
|
|
provider = OpenVikingMemoryProvider()
|
|
|
|
provider.initialize(
|
|
"session-1",
|
|
hermes_home=str(tmp_path),
|
|
platform="cli",
|
|
warning_callback=warnings.append,
|
|
)
|
|
|
|
assert provider._client is None
|
|
assert len(warnings) == 1
|
|
assert "/health response is not valid OpenViking" in warnings[0]
|
|
assert "python-http-server (PID 4242)" in warnings[0]
|
|
|
|
|
|
def test_is_available_true_for_config_yaml_endpoint(monkeypatch):
|
|
"""#68209: a config.yaml endpoint (no env, no ovcli) counts as available."""
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_load_hermes_openviking_config",
|
|
lambda: {"endpoint": "http://saved.test:1933"},
|
|
)
|
|
assert OpenVikingMemoryProvider().is_available() is True
|
|
|
|
|
|
def test_is_available_false_without_any_endpoint(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setattr(
|
|
openviking_module, "_load_hermes_openviking_config", lambda: {}
|
|
)
|
|
assert OpenVikingMemoryProvider().is_available() is False
|
|
|
|
|
|
class TestOpenVikingEnvWriter:
|
|
"""``_write_env_vars`` copies existing .env lines through on every update,
|
|
so how it *reads* them decides whether a credential update lands.
|
|
|
|
f1ea4a56c ("cover the remaining setup-time .env reads with utf-8-sig")
|
|
swept this class; this writer was missed.
|
|
"""
|
|
|
|
def test_bom_prefixed_env_updates_in_place(self, tmp_path):
|
|
from plugins.memory.openviking import _write_env_vars
|
|
|
|
env = tmp_path / ".env"
|
|
env.write_bytes(b"\xef\xbb\xbfOPENAI_API_KEY=old\nOTHER=1\n")
|
|
|
|
_write_env_vars(env, {"OPENAI_API_KEY": "new"})
|
|
|
|
lines = [l for l in env.read_text(encoding="utf-8-sig").splitlines() if l]
|
|
# The stale value must be gone, not left as a duplicate. Hermes and
|
|
# python-dotenv use the last occurrence, but the file must have one value.
|
|
assert lines.count("OPENAI_API_KEY=new") == 1
|
|
assert not any(l.endswith("=old") for l in lines)
|
|
assert "OTHER=1" in lines
|
|
|
|
def test_non_utf8_env_preserves_unrelated_bytes(self, tmp_path):
|
|
from plugins.memory.openviking import _write_env_vars
|
|
|
|
env = tmp_path / ".env"
|
|
env.write_bytes(b"NAME=caf\xe9\nOPENAI_API_KEY=old\n")
|
|
|
|
_write_env_vars(env, {"OPENAI_API_KEY": "new"})
|
|
|
|
assert env.read_bytes() == b"NAME=caf\xe9\nOPENAI_API_KEY=new\n"
|
|
|
|
def test_plain_env_is_unchanged_apart_from_the_write(self, tmp_path):
|
|
from plugins.memory.openviking import _write_env_vars
|
|
|
|
env = tmp_path / ".env"
|
|
env.write_text("A=1\nOPENAI_API_KEY=old\nB=2\n", encoding="utf-8")
|
|
|
|
_write_env_vars(env, {"OPENAI_API_KEY": "new"})
|
|
|
|
assert env.read_text(encoding="utf-8").splitlines() == [
|
|
"A=1", "OPENAI_API_KEY=new", "B=2",
|
|
]
|