Files
aiturk-hermes-ide/tests/plugins/memory/test_openviking_provider.py
T

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",
]