Files
hermes-agent/tests/openviking_plugin/test_openviking.py
T
ehz0ah 823bcc887a feat(openviking): use user memory by default
Remove the implicit hermes peer and the peer question from new connection setup. Preserve explicit peer settings and keep memory paths consistent with the captured client identity.

Add setup, configuration, request, recall, and session regression tests, plus upgrade guidance.
2026-08-31 17:20:45 +05:30

1475 lines
57 KiB
Python

"""Tests for plugins/memory/openviking/__init__.py — URI normalization and payload handling."""
import json
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, cast
from urllib.parse import parse_qs, urlparse
import pytest
import plugins.memory.openviking as openviking_plugin
from hermes_cli import __version__ as _HERMES_VERSION
from plugins.memory.openviking import OpenVikingMemoryProvider
def _write_skill(skills_dir, name, body="Do the thing."):
skill_dir = skills_dir / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Description for {name}\n---\n\n# {name}\n\n{body}\n",
encoding="utf-8",
)
return skill_dir
def _write_bundle(bundles_dir, slug, skills):
bundles_dir.mkdir(parents=True, exist_ok=True)
lines = [f"name: {slug}", "skills:"]
lines.extend(f" - {skill}" for skill in skills)
(bundles_dir / f"{slug}.yaml").write_text(
"\n".join(lines) + "\n",
encoding="utf-8",
)
class FakeVikingClient:
def __init__(self, responses):
self.responses = responses
self.calls = []
def get(self, path, params=None, **kwargs):
self.calls.append((path, params or {}))
response = self.responses[(path, tuple(sorted((params or {}).items())))]
if isinstance(response, Exception):
raise response
return response
def post(self, path, payload=None, **kwargs):
self.calls.append((path, payload or {}))
response = self.responses.get((path, tuple(sorted((payload or {}).items()))), {})
if isinstance(response, Exception):
raise response
return response
class RecordingVikingClient:
calls = []
def __init__(self, *args, **kwargs):
pass
def post(self, path, payload=None, **kwargs):
self.calls.append((path, payload or {}))
return {"result": {"memories": [], "resources": []}}
def _recall_context_key(value):
if isinstance(value, list):
return tuple(value)
return value
class FakeRecallClient:
calls = []
responses = {}
def __init__(self, *args, **kwargs):
pass
def post(self, path, payload=None, **kwargs):
payload = payload or {}
self.__class__.calls.append(("post", path, dict(payload)))
context_type = _recall_context_key(payload.get("context_type"))
key = (path, context_type, payload.get("query"), payload.get("session_id"))
if key not in self.__class__.responses:
key = (path, context_type, payload.get("query"))
if key not in self.__class__.responses:
key = (path, context_type)
response = self.__class__.responses[key]
if isinstance(response, Exception):
raise response
return response
def get(self, path, params=None, **kwargs):
params = params or {}
self.__class__.calls.append(("get", path, dict(params)))
response = self.__class__.responses[(path, params.get("uri"))]
if isinstance(response, Exception):
raise response
return response
def make_prefetch_provider(monkeypatch, responses, **env):
monkeypatch.setattr(openviking_plugin, "_VikingClient", FakeRecallClient)
FakeRecallClient.calls = []
FakeRecallClient.responses = responses
for key in (
"OPENVIKING_RECALL_LIMIT",
"OPENVIKING_RECALL_SCORE_THRESHOLD",
"OPENVIKING_RECALL_MAX_INJECTED_CHARS",
"OPENVIKING_RECALL_TIMEOUT_SECONDS",
"OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS",
"OPENVIKING_RECALL_FULL_READ_LIMIT",
"OPENVIKING_RECALL_PREFER_ABSTRACT",
"OPENVIKING_RECALL_RESOURCES",
"OPENVIKING_PROFILE_TOKEN_BUDGET",
):
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, str(value))
provider = OpenVikingMemoryProvider()
provider._client = object()
provider._endpoint = "http://openviking.test"
provider._account = "default"
provider._user = "default"
provider._agent = "hermes"
provider._session_id = "session-test"
return provider
def wait_prefetch(provider, query="What should we recall?", session_id="session-test"):
return provider.prefetch(query, session_id=session_id)
class TestOpenVikingSummaryUriNormalization:
def test_normalize_summary_uri_maps_pseudo_files_to_parent_directory(self):
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://user/hermes/.overview.md") == "viking://user/hermes"
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://resources/.abstract.md") == "viking://resources"
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://") == "viking://"
assert OpenVikingMemoryProvider._normalize_summary_uri("viking://user/hermes/memories/profile.md") == "viking://user/hermes/memories/profile.md"
class TestOpenVikingSkillQuerySafety:
def test_derive_returns_empty_string_for_non_string_input(self):
assert openviking_plugin._derive_openviking_user_text(None) == ""
assert openviking_plugin._derive_openviking_user_text(123) == ""
assert openviking_plugin._derive_openviking_user_text([{"text": "hi"}]) == ""
def test_skill_markers_match_hermes_scaffolding(self, tmp_path, monkeypatch):
import agent.skill_bundles as skill_bundles
import agent.skill_commands as skill_commands
import tools.skills_tool as skills_tool
skills_dir = tmp_path / "skills"
bundles_dir = tmp_path / "skill-bundles"
_write_skill(skills_dir, "example")
_write_bundle(bundles_dir, "demo", ["example"])
monkeypatch.setattr(skills_tool, "SKILLS_DIR", skills_dir)
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
monkeypatch.setattr(skill_commands, "_skill_commands", {})
monkeypatch.setattr(skill_commands, "_skill_commands_platform", None)
monkeypatch.setattr(skill_bundles, "_bundles_cache", {})
monkeypatch.setattr(skill_bundles, "_bundles_cache_mtime", None)
skill_commands.scan_skill_commands()
single = skill_commands.build_skill_invocation_message(
"/example",
user_instruction="hello",
runtime_note="runtime detail",
)
assert single is not None
assert skill_commands._SKILL_INVOCATION_PREFIX in single
assert skill_commands._SINGLE_SKILL_MARKER in single
assert skill_commands._SINGLE_SKILL_INSTRUCTION in single
assert skill_commands._RUNTIME_NOTE in single
skill_bundles.scan_bundles()
bundle_result = skill_bundles.build_bundle_invocation_message(
"/demo",
user_instruction="hello",
)
assert bundle_result is not None
bundle, _, _ = bundle_result
assert skill_commands._BUNDLE_MARKER in bundle
assert skill_commands._BUNDLE_USER_INSTRUCTION in bundle
assert skill_commands._BUNDLE_FIRST_SKILL_BLOCK in bundle
def test_prefetch_searches_only_slash_skill_user_instruction(self, monkeypatch):
RecordingVikingClient.calls = []
monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
provider = OpenVikingMemoryProvider()
provider._client = cast(Any, object())
provider._endpoint = "http://openviking.test"
provider._api_key = ""
provider._account = "default"
provider._user = "default"
provider._agent = "hermes"
skill_message = (
'[IMPORTANT: The user has invoked the "skill-creator" skill, indicating they want '
"you to follow its instructions. The full skill content is loaded below.]\n\n"
"# Skill Creator\n\n"
"Large skill body that must not be searched or embedded.\n\n"
"The user has provided the following instruction alongside the skill invocation: "
"make a skill for release triage"
)
provider.prefetch(skill_message)
assert RecordingVikingClient.calls == [
(
"/api/v1/search/find",
{
"query": "make a skill for release triage",
"limit": 24,
"score_threshold": 0,
"context_type": "memory",
},
),
]
def test_sync_turn_stores_only_slash_skill_user_instruction(self, monkeypatch):
RecordingVikingClient.calls = []
monkeypatch.setattr(openviking_plugin, "_VikingClient", RecordingVikingClient)
provider = OpenVikingMemoryProvider()
provider._client = cast(Any, object())
provider._endpoint = "http://openviking.test"
provider._api_key = ""
provider._account = "default"
provider._user = "default"
provider._agent = "hermes"
provider._session_id = "session-1"
skill_message = (
'[IMPORTANT: The user has invoked the "skill-creator" skill, indicating they want '
"you to follow its instructions. The full skill content is loaded below.]\n\n"
"# Skill Creator\n\n"
"Large skill body that must not be stored as user content.\n\n"
"The user has provided the following instruction alongside the skill invocation: "
"make a skill for release triage"
)
provider.sync_turn(skill_message, "Done.")
assert provider._drain_writers("session-1", timeout=5.0)
assert RecordingVikingClient.calls == [
(
"/api/v1/sessions/session-1/messages/batch",
{
"messages": [
{
"role": "user",
"parts": [
{"type": "text", "text": "make a skill for release triage"},
],
},
{
"role": "assistant",
"parts": [{"type": "text", "text": "Done."}],
"peer_id": "hermes",
},
]
},
),
]
class TestOpenVikingConfigSchema:
def test_recall_policy_options_are_exposed_in_setup_schema(self):
provider = OpenVikingMemoryProvider()
schema = provider.get_config_schema()
fields = {entry["key"]: entry for entry in schema}
env_vars = {entry.get("env_var") for entry in schema}
assert "OPENVIKING_RECALL_LIMIT" in env_vars
assert "OPENVIKING_RECALL_SCORE_THRESHOLD" in env_vars
assert "OPENVIKING_RECALL_MAX_INJECTED_CHARS" in env_vars
assert "OPENVIKING_RECALL_TIMEOUT_SECONDS" in env_vars
assert "OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS" in env_vars
assert "OPENVIKING_RECALL_FULL_READ_LIMIT" in env_vars
assert "OPENVIKING_RECALL_PREFER_ABSTRACT" in env_vars
assert "OPENVIKING_RECALL_RESOURCES" in env_vars
assert fields["recall_limit"]["type"] == "integer"
assert fields["recall_limit"]["minimum"] == 1
assert fields["recall_limit"]["maximum"] == 100
assert fields["recall_score_threshold"]["type"] == "number"
assert fields["recall_prefer_abstract"]["type"] == "boolean"
assert provider._recall_config() == {
"limit": 6,
"score_threshold": 0.15,
"max_injected_chars": 4000,
"timeout_seconds": 4.0,
"request_timeout_seconds": 3.0,
"full_read_limit": 2,
"prefer_abstract": False,
"resources": False,
}
def test_recall_config_reads_from_config_yaml(self, monkeypatch, tmp_path):
"""_recall_config() reads memory.openviking values from config.yaml when
the corresponding OPENVIKING_RECALL_* env vars are not set."""
# Populate config.yaml in the temp HERMES_HOME
hermes_home = tmp_path / "hermes_test"
hermes_home.mkdir(exist_ok=True)
config_yaml = hermes_home / "config.yaml"
config_yaml.write_text(
"""\
memory:
provider: openviking
openviking:
recall_limit: 12
recall_score_threshold: 0.42
recall_max_injected_chars: 8000
profile_token_budget: 7000
recall_timeout_seconds: 2.0
recall_request_timeout_seconds: 1.5
recall_full_read_limit: 5
recall_prefer_abstract: true
recall_resources: true
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Clear any OPENVIKING_RECALL_* env vars so config.yaml prevails
for key in list(os.environ):
if key.startswith("OPENVIKING_RECALL_"):
monkeypatch.delenv(key, raising=False)
provider = OpenVikingMemoryProvider()
cfg = provider._recall_config()
assert cfg["limit"] == 12
assert cfg["score_threshold"] == 0.42
assert cfg["max_injected_chars"] == 8000
assert cfg["timeout_seconds"] == 2.0
assert cfg["request_timeout_seconds"] == 1.5
assert cfg["full_read_limit"] == 5
assert cfg["prefer_abstract"] is True
assert cfg["resources"] is True
assert provider._profile_token_budget() == 7000
def test_recall_config_env_overrides_config_yaml(self, monkeypatch, tmp_path):
"""Env vars OPENVIKING_RECALL_* take precedence over config.yaml values
when both are present."""
hermes_home = tmp_path / "hermes_test"
hermes_home.mkdir(exist_ok=True)
config_yaml = hermes_home / "config.yaml"
config_yaml.write_text(
"""\
memory:
provider: openviking
openviking:
recall_limit: 12
recall_resources: true
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Override config.yaml via env
monkeypatch.setenv("OPENVIKING_RECALL_LIMIT", "6")
monkeypatch.setenv("OPENVIKING_RECALL_RESOURCES", "false")
provider = OpenVikingMemoryProvider()
cfg = provider._recall_config()
assert cfg["limit"] == 6, "env var should override config.yaml"
assert cfg["resources"] is False, "env var false should override config.yaml true"
def test_recall_config_partial_config_yaml(self, monkeypatch, tmp_path):
"""Partially populated config.yaml falls back to defaults for omitted keys
and env vars can override individual fields."""
hermes_home = tmp_path / "hermes_test"
hermes_home.mkdir(exist_ok=True)
config_yaml = hermes_home / "config.yaml"
config_yaml.write_text(
"""\
memory:
provider: openviking
openviking:
recall_limit: 3
# No recall_resources set — should use default (False)
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
for key in list(os.environ):
if key.startswith("OPENVIKING_RECALL_"):
monkeypatch.delenv(key, raising=False)
provider = OpenVikingMemoryProvider()
cfg = provider._recall_config()
assert cfg["limit"] == 3, "config.yaml value should be picked up"
assert cfg["resources"] is False, "omitted key should use default"
assert cfg["timeout_seconds"] == 4.0, "omitted key should use built-in default"
def test_dashboard_shaped_string_values_are_typed(self, monkeypatch):
for key in list(os.environ):
if key.startswith("OPENVIKING_RECALL_") or key == "OPENVIKING_PROFILE_TOKEN_BUDGET":
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(
openviking_plugin,
"_load_hermes_openviking_config",
lambda: {
"recall_limit": "12",
"recall_score_threshold": "0.42",
"recall_prefer_abstract": "false",
"recall_resources": "true",
"profile_token_budget": "7500",
},
)
provider = OpenVikingMemoryProvider()
cfg = provider._recall_config()
assert cfg["limit"] == 12
assert cfg["score_threshold"] == 0.42
assert cfg["prefer_abstract"] is False
assert cfg["resources"] is True
assert provider._profile_token_budget() == 7500
def test_invalid_recall_values_fall_back_without_type_errors(self, monkeypatch):
for key in list(os.environ):
if key.startswith("OPENVIKING_RECALL_") or key == "OPENVIKING_PROFILE_TOKEN_BUDGET":
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(
openviking_plugin,
"_load_hermes_openviking_config",
lambda: {
"recall_limit": "many",
"recall_score_threshold": True,
"recall_prefer_abstract": "sometimes",
"profile_token_budget": "7.5",
},
)
provider = OpenVikingMemoryProvider()
cfg = provider._recall_config()
assert cfg["limit"] == 6
assert cfg["score_threshold"] == 0.15
assert cfg["prefer_abstract"] is False
assert provider._profile_token_budget() == 6000
def test_recall_env_overrides_string_config_with_native_types(self, monkeypatch):
monkeypatch.setattr(
openviking_plugin,
"_load_hermes_openviking_config",
lambda: {"recall_limit": "12", "recall_resources": "false"},
)
monkeypatch.setenv("OPENVIKING_RECALL_LIMIT", "4")
monkeypatch.setenv("OPENVIKING_RECALL_RESOURCES", "true")
cfg = OpenVikingMemoryProvider()._recall_config()
assert cfg["limit"] == 4
assert cfg["resources"] is True
class TestOpenVikingTurnConversion:
def test_extract_current_turn_anchors_on_latest_matching_user_and_assistant(self):
messages = [
{"role": "user", "content": "Please inspect the repository for assemble hooks."},
{"role": "assistant", "content": "Earlier answer."},
{"role": "user", "content": "Please inspect the repository for assemble hooks."},
{
"role": "assistant",
"content": "I will search the codebase.",
"tool_calls": [
{
"id": "call_rg_1",
"type": "function",
"function": {
"name": "shell_command",
"arguments": json.dumps({"command": "rg assemble"}),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_rg_1",
"name": "shell_command",
"content": "agent/context_engine.py: no preassemble hook",
},
{"role": "assistant", "content": "The current main does not expose assemble."},
]
turn = OpenVikingMemoryProvider._extract_current_turn_messages(
messages,
"Please inspect the repository for assemble hooks.",
"The current main does not expose assemble.",
)
assert turn == messages[2:]
def test_messages_to_openviking_batch_coalesces_tool_results(self):
turn = [
{"role": "user", "content": "Please inspect the repository for assemble hooks."},
{
"role": "assistant",
"content": "I will search the codebase.",
"tool_calls": [
{
"id": "call_rg_1",
"type": "function",
"function": {
"name": "shell_command",
"arguments": json.dumps({"command": "rg assemble"}),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_rg_1",
"name": "shell_command",
"content": "agent/context_engine.py: no preassemble hook",
},
{"role": "assistant", "content": "The current main does not expose assemble."},
]
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
assert [message["role"] for message in batch] == ["user", "assistant", "assistant", "assistant"]
assert batch[0]["parts"] == [
{"type": "text", "text": "Please inspect the repository for assemble hooks."}
]
assert batch[1]["parts"] == [
{"type": "text", "text": "I will search the codebase."}
]
assert batch[2]["parts"] == [
{
"type": "tool",
"tool_id": "call_rg_1",
"tool_name": "shell_command",
"tool_input": {"command": "rg assemble"},
"tool_output": "agent/context_engine.py: no preassemble hook",
"tool_status": "completed",
}
]
assert batch[3]["parts"] == [
{"type": "text", "text": "The current main does not expose assemble."}
]
class TestOpenVikingRead:
def test_overview_read_normalizes_uri_and_unwraps_result(self):
provider = OpenVikingMemoryProvider()
provider._client = FakeVikingClient(
{
(
"/api/v1/content/overview",
(("uri", "viking://user/hermes"),),
): {"result": {"content": "overview text"}},
}
)
result = json.loads(provider._tool_read({"uri": "viking://user/hermes/.overview.md", "level": "overview"}))
assert result["uri"] == "viking://user/hermes/.overview.md"
assert result["resolved_uri"] == "viking://user/hermes"
assert result["level"] == "overview"
assert result["content"] == "overview text"
assert provider._client.calls == [(
"/api/v1/content/overview",
{"uri": "viking://user/hermes"},
)]
def test_read_accepts_uri_batch_and_caps_batch_full_content(self):
provider = OpenVikingMemoryProvider()
uris = [
"viking://user/hermes/memories/a.md",
"viking://user/hermes/memories/b.md",
"viking://user/hermes/memories/c.md",
"viking://user/hermes/memories/d.md",
]
provider._client = FakeVikingClient(
{
(
"/api/v1/content/read",
(("uri", uris[0]),),
): {"result": {"content": "a" * 3000}},
(
"/api/v1/content/read",
(("uri", uris[1]),),
): {"result": {"content": "b content"}},
(
"/api/v1/content/read",
(("uri", uris[2]),),
): {"result": {"content": "c content"}},
}
)
result = json.loads(provider._tool_read({"uris": uris, "level": "full"}))
assert result["requested"] == 4
assert result["returned"] == 3
assert result["truncated"] is True
assert [entry["uri"] for entry in result["results"]] == uris[:3]
assert result["results"][0]["content"].endswith(
"[... truncated, use a more specific URI or full level]"
)
assert len(result["results"][0]["content"]) < 2700
assert provider._client.calls == [
("/api/v1/content/read", {"uri": uris[0]}),
("/api/v1/content/read", {"uri": uris[1]}),
("/api/v1/content/read", {"uri": uris[2]}),
]
def test_summary_uri_error_does_not_fallback_and_raises(self):
provider = OpenVikingMemoryProvider()
provider._client = FakeVikingClient(
{
(
"/api/v1/content/overview",
(("uri", "viking://user/hermes"),),
): RuntimeError("500 Internal Server Error"),
}
)
try:
provider._tool_read({"uri": "viking://user/hermes/.overview.md", "level": "overview"})
assert False, "Expected summary endpoint error to be raised"
except RuntimeError:
pass
assert provider._client.calls == [
("/api/v1/content/overview", {"uri": "viking://user/hermes"}),
]
class TestOpenVikingAutoRecallPrefetch:
@pytest.mark.parametrize("peer", ["", "hermes"])
def test_prefetch_e2e_sends_limit_and_reads_l2_content(self, monkeypatch, peer):
records = {"searches": [], "reads": [], "listings": [], "headers": []}
class Handler(BaseHTTPRequestHandler):
def _send_json(self, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
self._send_json({"status": "ok", "healthy": True, "version": "test"})
return
records["headers"].append(dict(self.headers))
if parsed.path == "/api/v1/system/status":
self._send_json({"status": "ok", "result": {"user": "user"}})
return
if parsed.path == "/api/v1/content/read":
query = parse_qs(parsed.query)
uri = query.get("uri", [""])[0]
if uri == "viking://user/user/memories/profile.md":
self._send_json({"result": "E2E user profile."})
return
records["reads"].append(uri)
self._send_json({"result": {"content": "E2E full L2 memory content."}})
return
if parsed.path == "/api/v1/fs/ls":
query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
records["listings"].append(query)
uri = query.get("uri")
if uri == "viking://user/user/memories/preferences":
self._send_json({
"result": [
{"isDir": True, "rel_path": "owner", "abstract": "ignored"},
{
"isDir": False,
"rel_path": "owner/answers.md",
"abstract": "Prefers source-backed answers.",
},
]
})
return
if uri == "viking://user/user/memories/entities":
self._send_json({
"result": [
{
"isDir": False,
"rel_path": "people/ada.md",
"abstract": "Ada is the project owner.",
}
]
})
return
self.send_error(404)
return
self.send_error(404)
def do_POST(self):
length = int(self.headers.get("Content-Length", "0") or "0")
payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
records["headers"].append(dict(self.headers))
if self.path == "/api/v1/search/search":
records["searches"].append(payload)
if payload.get("context_type") == "memory":
self._send_json({
"result": {
"memories": [
{
"uri": "viking://user/user/peers/hermes/memories/e2e-full.md",
"score": 0.9,
"level": 2,
"category": "events",
"abstract": "E2E abstract should not be injected.",
}
],
"resources": [],
}
})
else:
self._send_json({"result": {"memories": [], "resources": []}})
return
self.send_error(404)
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
endpoint = f"http://127.0.0.1:{server.server_port}"
for key in (
"OPENVIKING_RECALL_LIMIT",
"OPENVIKING_RECALL_SCORE_THRESHOLD",
"OPENVIKING_RECALL_MAX_INJECTED_CHARS",
"OPENVIKING_RECALL_PREFER_ABSTRACT",
"OPENVIKING_RECALL_RESOURCES",
"OPENVIKING_PROFILE_TOKEN_BUDGET",
"OPENVIKING_API_KEY",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("OPENVIKING_ENDPOINT", endpoint)
monkeypatch.setenv("OPENVIKING_ACCOUNT", "acct")
monkeypatch.setenv("OPENVIKING_USER", "user")
monkeypatch.setenv("OPENVIKING_AGENT", peer)
provider = OpenVikingMemoryProvider()
try:
provider.initialize("e2e-session")
block = provider.prefetch("What should we recall?", session_id="e2e-session")
finally:
provider.shutdown()
server.shutdown()
server.server_close()
thread.join(timeout=3.0)
assert block.startswith("## OpenViking Context\n")
assert "E2E user profile." in block
assert "owner/answers.md — Prefers source-backed answers." in block
assert "people/ada.md — Ada is the project owner." in block
assert "E2E full L2 memory content." in block
assert "E2E abstract should not be injected." not in block
assert records["reads"] == ["viking://user/user/peers/hermes/memories/e2e-full.md"]
assert [listing["uri"] for listing in records["listings"]] == [
"viking://user/user/memories/preferences",
"viking://user/user/memories/entities",
]
assert all(listing["output"] == "agent" for listing in records["listings"])
assert all(listing["recursive"].lower() == "true" for listing in records["listings"])
assert all(listing["abs_limit"] == "512" for listing in records["listings"])
assert all(listing["node_limit"] == "512" for listing in records["listings"])
assert len(records["searches"]) == 1
assert records["searches"][0]["context_type"] == "memory"
assert records["searches"][0]["session_id"] == "e2e-session"
assert "target_uri" not in records["searches"][0]
assert all(payload["limit"] == 24 for payload in records["searches"])
assert all("top_k" not in payload for payload in records["searches"])
assert all("mode" not in payload for payload in records["searches"])
assert all(payload["score_threshold"] == 0 for payload in records["searches"])
normalized_headers = [
{key.lower(): value for key, value in headers.items()}
for headers in records["headers"]
]
assert all(headers.get("x-openviking-actor-peer", "") == peer for headers in normalized_headers)
assert all(
headers.get("user-agent") == f"openviking-memory-hermes/{_HERMES_VERSION}"
for headers in normalized_headers
)
assert all(headers.get("x-openviking-account") == "acct" for headers in normalized_headers)
assert all(headers.get("x-openviking-user") == "user" for headers in normalized_headers)
class TestOpenVikingBrowse:
def test_list_browse_unwraps_and_normalizes_entry_shapes(self):
provider = OpenVikingMemoryProvider()
provider._client = FakeVikingClient(
{
(
"/api/v1/fs/ls",
(("uri", "viking://user/hermes"),),
): {
"result": {
"entries": [
{"name": "memories", "uri": "viking://user/hermes/memories", "type": "dir"},
{"rel_path": "profile.md", "uri": "viking://user/hermes/memories/profile.md", "isDir": False, "abstract": "Profile"},
]
}
},
}
)
result = json.loads(provider._tool_browse({"action": "list", "path": "viking://user/hermes"}))
assert result["path"] == "viking://user/hermes"
assert result["entries"] == [
{"name": "memories", "uri": "viking://user/hermes/memories", "type": "dir", "abstract": ""},
{"name": "profile.md", "uri": "viking://user/hermes/memories/profile.md", "type": "file", "abstract": "Profile"},
]
assert provider._client.calls == [(
"/api/v1/fs/ls",
{"uri": "viking://user/hermes"},
)]
class TestOpenVikingMemoryUriBuilder:
"""Regression tests for _build_memory_uri — fixes #36969.
OpenViking's current memory layout stores peer-scoped memories under
viking://user/{user}/peers/{peer_id}/...
"""
def _make_provider(self, user="alice", agent="coder"):
p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider)
p._user = user
p._agent = agent
return p
def test_uri_layout_includes_peer_segment(self):
"""URI must contain /peers/{peer_id}/ between user and memories."""
p = self._make_provider(user="alice", agent="coder")
uri = p._build_memory_uri("preferences")
assert uri.startswith("viking://user/alice/peers/coder/memories/preferences/mem_")
assert uri.endswith(".md")
# ===================================================================
# Issue #21130 — OPENVIKING_* not reloaded after /reload
# ===================================================================
class TestEnsureClientReloadsEnv:
"""Verify /reload picks up new OPENVIKING_* values without a restart (#21130)."""
def test_ensure_client_rebuilds_when_api_key_changes(self, monkeypatch):
constructions = []
class _StubClient:
def __init__(self, endpoint, api_key, account="", user="", agent="hermes"):
constructions.append({"endpoint": endpoint, "api_key": api_key,
"account": account, "user": user, "agent": agent})
self.endpoint, self.api_key = endpoint, api_key
self.account, self.user, self.agent = account, user, agent
def health(self):
return True
monkeypatch.setattr("plugins.memory.openviking._VikingClient", _StubClient)
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://srv:31933")
monkeypatch.setenv("OPENVIKING_API_KEY", "")
provider = OpenVikingMemoryProvider()
provider._env_refresh_enabled = True
first = provider._ensure_client()
assert first is not None
assert first.api_key == ""
assert len(constructions) == 1
# Same env on second call — must reuse cached client (no rebuild).
assert provider._ensure_client() is first
assert len(constructions) == 1
# Simulate /reload: env now carries the new API key.
monkeypatch.setenv("OPENVIKING_API_KEY", "sk-fresh")
rebuilt = provider._ensure_client()
assert rebuilt is not None
assert rebuilt is not first
assert rebuilt.api_key == "sk-fresh"
assert len(constructions) == 2
def test_rebuilt_client_resolves_its_own_user_space(self, monkeypatch):
class _StubClient:
def __init__(self, endpoint, api_key="", account="", user="", agent="hermes"):
self.endpoint = endpoint
self.api_key = api_key
self.account = account
self.user = user
self.agent = agent
def health(self):
return True
def get(self, path):
assert path == "/api/v1/system/status"
return {"status": "ok", "result": {"user": self.user}}
monkeypatch.setattr("plugins.memory.openviking._VikingClient", _StubClient)
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://srv:31933")
monkeypatch.setenv("OPENVIKING_API_KEY", "")
monkeypatch.setenv("OPENVIKING_USER", "alice")
monkeypatch.setenv("OPENVIKING_AGENT", "hermes")
provider = OpenVikingMemoryProvider()
provider._env_refresh_enabled = True
alice_client = provider._ensure_client()
alice_uri = provider._build_memory_uri("preferences")
monkeypatch.setenv("OPENVIKING_USER", "bob")
bob_client = provider._ensure_client()
bob_uri = provider._build_memory_uri("preferences")
assert bob_client is not alice_client
assert alice_uri.startswith("viking://user/alice/peers/hermes/")
assert bob_uri.startswith("viking://user/bob/peers/hermes/")
def test_handle_tool_call_reconnects_after_startup_health_failure(self, monkeypatch):
instances = []
class _StubClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
self.api_key = api_key
self.account = account
self.user = user
self.agent = agent
self.index = len(instances)
self.posts = []
instances.append(self)
def health(self):
return self.index > 0
def post(self, path, payload=None, **kwargs):
self.posts.append((path, payload or {}))
return {"result": {"written_bytes": 11}}
monkeypatch.setattr("plugins.memory.openviking._VikingClient", _StubClient)
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://openviking.example")
monkeypatch.setenv("OPENVIKING_API_KEY", "sk-test")
monkeypatch.setenv("OPENVIKING_AGENT", "hermes")
provider = OpenVikingMemoryProvider()
provider.initialize("session-1")
assert provider._client is None
out = json.loads(provider.handle_tool_call(
"viking_remember",
{"content": "stable fact"},
))
assert out["status"] == "stored"
assert len(instances) == 2
assert instances[1].posts[0][0] == "/api/v1/content/write"
assert instances[1].posts[0][1]["content"] == "stable fact"
assert instances[1].posts[0][1]["mode"] == "create"
assert instances[1].posts[0][1]["uri"].startswith(
"viking://user/default/peers/hermes/memories/"
)
def test_concurrent_refresh_does_not_return_stale_client(self, monkeypatch):
refresh_entered = threading.Event()
release_refresh = threading.Event()
class _StubClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
self.api_key = api_key
def health(self):
if self.endpoint == "https://new.example":
refresh_entered.set()
assert release_refresh.wait(2.0)
return False
return True
monkeypatch.setattr(openviking_plugin, "_VikingClient", _StubClient)
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://new.example")
monkeypatch.setenv("OPENVIKING_API_KEY", "sk-new")
monkeypatch.delenv("OPENVIKING_ACCOUNT", raising=False)
monkeypatch.delenv("OPENVIKING_USER", raising=False)
monkeypatch.delenv("OPENVIKING_AGENT", raising=False)
provider = OpenVikingMemoryProvider()
stale_client = _StubClient("https://old.example", "sk-old")
provider._endpoint = stale_client.endpoint
provider._api_key = stale_client.api_key
provider._account = ""
provider._user = ""
provider._agent = "hermes"
provider._client = stale_client
provider._env_refresh_enabled = True
results = {}
errors = []
second_started = threading.Event()
second_done = threading.Event()
def refresh_client(name, *, started=None, done=None):
if started is not None:
started.set()
try:
results[name] = provider._ensure_client()
except BaseException as exc: # pragma: no cover - surfaced below
errors.append(exc)
finally:
if done is not None:
done.set()
first = threading.Thread(target=refresh_client, args=("first",))
first.start()
assert refresh_entered.wait(2.0)
second = threading.Thread(
target=refresh_client,
args=("second",),
kwargs={"started": second_started, "done": second_done},
)
second.start()
assert second_started.wait(2.0)
completed_during_refresh = second_done.wait(0.2)
release_refresh.set()
first.join(timeout=2.0)
second.join(timeout=2.0)
assert not first.is_alive()
assert not second.is_alive()
assert errors == []
assert completed_during_refresh is False
assert results == {"first": None, "second": None}
assert all(client is not stale_client for client in results.values())
def test_concurrent_local_runtime_recovery_starts_once(self, monkeypatch):
class _AliveThread:
def is_alive(self):
return True
provider = OpenVikingMemoryProvider()
provider._endpoint = "http://127.0.0.1:31933"
start_calls = []
start_lock = threading.Lock()
first_start_entered = threading.Event()
release_start = threading.Event()
second_started = threading.Event()
def start_local(endpoint):
with start_lock:
start_calls.append(endpoint)
first_start_entered.set()
release_start.wait(timeout=2)
return openviking_plugin._LOCAL_SERVER_STARTED, "started"
monkeypatch.setattr(openviking_plugin, "_start_local_openviking_server", start_local)
monkeypatch.setattr(
provider,
"_start_runtime_openviking_waiter",
lambda **kwargs: setattr(provider, "_runtime_start_thread", _AliveThread()),
raising=False,
)
errors = []
def recover(*, started=None):
if started is not None:
started.set()
try:
provider._handle_runtime_openviking_unreachable()
except BaseException as exc: # pragma: no cover - surfaced below
errors.append(exc)
threads = [
threading.Thread(target=recover, name="openviking-recovery-1"),
threading.Thread(
target=recover,
kwargs={"started": second_started},
name="openviking-recovery-2",
),
]
for thread in threads:
thread.start()
assert first_start_entered.wait(timeout=2)
assert second_started.wait(timeout=2)
release_start.set()
for thread in threads:
thread.join(timeout=2)
assert not thread.is_alive()
assert errors == []
assert start_calls == ["http://127.0.0.1:31933"]
class TestEnsureClientFailureHardening:
"""Follow-up hardening on top of #21130: failed-config cooldown and
torn-identity-safe connection snapshots."""
def test_failed_config_probes_once_then_cools_down(self, monkeypatch):
"""A down endpoint must not pay a health probe on every access."""
probes = []
class _StubClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
def health(self):
probes.append(self.endpoint)
return False
monkeypatch.setattr(openviking_plugin, "_VikingClient", _StubClient)
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://down.example")
monkeypatch.setenv("OPENVIKING_API_KEY", "sk-x")
provider = OpenVikingMemoryProvider()
provider._env_refresh_enabled = True
assert provider._ensure_client() is None
assert provider._ensure_client() is None
assert provider._ensure_client() is None
# Only the first access probes; the rest hit the cooldown.
assert len(probes) == 1
def test_conn_snapshot_published_only_on_healthy(self, monkeypatch):
class _StubClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
def health(self):
return self.endpoint == "https://up.example"
monkeypatch.setattr(openviking_plugin, "_VikingClient", _StubClient)
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://up.example")
monkeypatch.setenv("OPENVIKING_API_KEY", "sk-good")
monkeypatch.delenv("OPENVIKING_ACCOUNT", raising=False)
monkeypatch.delenv("OPENVIKING_USER", raising=False)
monkeypatch.delenv("OPENVIKING_AGENT", raising=False)
provider = OpenVikingMemoryProvider()
provider._env_refresh_enabled = True
assert provider._ensure_client() is not None
healthy_snapshot = provider._conn_snapshot
assert healthy_snapshot is not None
assert healthy_snapshot[0] == "https://up.example"
assert healthy_snapshot[1] == "sk-good"
# A failed refresh must NOT advance the snapshot: background writers
# (_new_client) keep targeting the last identity that passed health.
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://down.example")
assert provider._ensure_client() is None
assert provider._conn_snapshot == healthy_snapshot
built = provider._new_client()
assert built.endpoint == "https://up.example"
class TestUnavailableWarningsPromiseRetry:
"""Every "OpenViking is unavailable" warning must describe what actually
happens next.
``_ensure_client()`` rebuilds and re-probes the client whenever the
resolved config changes or the failed-config cooldown has elapsed, so no
warning may tell the user memory is off for the rest of the run — that
reads as "it never recovers" and sends people restarting hermes for
nothing (#5721).
"""
@staticmethod
def _assert_promises_retry(message: str) -> None:
assert "for this Hermes run" not in message, message
assert "will retry on a later access" in message, message
assert "when the config changes" in message, message
@staticmethod
def _stub_client(health_result):
class _StubClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
def health(self):
return health_result
return _StubClient
def test_local_autostart_timeout_warning(self):
self._assert_promises_retry(
openviking_plugin._runtime_openviking_timeout_message("http://127.0.0.1:1934")
)
def test_remote_unreachable_warning(self):
provider = OpenVikingMemoryProvider()
provider._endpoint = "https://remote.example"
warnings: list[str] = []
provider._handle_runtime_openviking_unreachable(warning_callback=warnings.append)
assert provider._client is None
assert len(warnings) == 1
self._assert_promises_retry(warnings[0])
def test_local_autostart_refused_warning(self, monkeypatch):
monkeypatch.setattr(
openviking_plugin,
"_start_local_openviking_server",
lambda endpoint: (
openviking_plugin._LOCAL_SERVER_FAILED,
"openviking-server was not found on PATH.",
),
)
provider = OpenVikingMemoryProvider()
provider._endpoint = "http://127.0.0.1:1934"
warnings: list[str] = []
provider._handle_runtime_openviking_unreachable(warning_callback=warnings.append)
assert provider._client is None
assert len(warnings) == 1
self._assert_promises_retry(warnings[0])
def test_still_unhealthy_after_autostart_warning(self, monkeypatch):
monkeypatch.setattr(openviking_plugin, "_VikingClient", self._stub_client(False))
monkeypatch.setattr(
openviking_plugin, "_wait_for_openviking_health", lambda endpoint, **kwargs: True
)
provider = OpenVikingMemoryProvider()
provider._endpoint = "http://127.0.0.1:1934"
warnings: list[str] = []
provider._finish_runtime_openviking_start(warning_callback=warnings.append)
assert provider._client is None
assert len(warnings) == 1
self._assert_promises_retry(warnings[0])
def test_attach_failure_after_autostart_warning(self, monkeypatch):
def _explode(*args, **kwargs):
raise RuntimeError("connection reset by peer")
monkeypatch.setattr(openviking_plugin, "_VikingClient", _explode)
monkeypatch.setattr(
openviking_plugin, "_wait_for_openviking_health", lambda endpoint, **kwargs: True
)
provider = OpenVikingMemoryProvider()
provider._endpoint = "http://127.0.0.1:1934"
warnings: list[str] = []
provider._finish_runtime_openviking_start(warning_callback=warnings.append)
assert provider._client is None
assert len(warnings) == 1
self._assert_promises_retry(warnings[0])
def test_initialize_responded_unhealthy_warning(self, monkeypatch, tmp_path):
class _UnhealthyClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
def health_payload(self):
return {"healthy": False}
def health(self):
return False
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://sick.example")
monkeypatch.setattr(openviking_plugin, "_VikingClient", _UnhealthyClient)
provider = OpenVikingMemoryProvider()
warnings: list[str] = []
provider.initialize("session-1", platform="cli", warning_callback=warnings.append)
assert provider._client is None
assert len(warnings) == 1
self._assert_promises_retry(warnings[0])
def test_ensure_client_responded_unhealthy_warning(self, monkeypatch, caplog):
class _UnhealthyClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
def health_payload(self):
return {"healthy": False}
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://sick.example")
monkeypatch.setattr(openviking_plugin, "_VikingClient", _UnhealthyClient)
provider = OpenVikingMemoryProvider()
provider._env_refresh_enabled = True
with caplog.at_level("WARNING", logger=openviking_plugin.__name__):
assert provider._ensure_client() is None
self._assert_promises_retry(caplog.text)
def test_startup_failure_really_does_reconnect_on_a_later_access(
self, monkeypatch, tmp_path
):
"""The warnings promise a retry — prove the provider delivers one."""
probes: list[str] = []
class _FlakyClient:
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
self.endpoint = endpoint
def health(self):
probes.append(self.endpoint)
return len(probes) > 1 # down at startup, up on the next access
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://remote.example")
monkeypatch.setattr(openviking_plugin, "_VikingClient", _FlakyClient)
provider = OpenVikingMemoryProvider()
warnings: list[str] = []
provider.initialize("session-1", platform="cli", warning_callback=warnings.append)
assert provider._client is None
assert len(warnings) == 1
self._assert_promises_retry(warnings[0])
# A startup failure arms no cooldown, so the very next access re-probes.
client = provider._ensure_client()
assert client is not None
assert client.endpoint == "https://remote.example"
assert len(probes) == 2
# ===================================================================
# Explicit-uid URIs (#91995 review) — `~` only expands for USER/ADMIN
# ===================================================================
class TestResolveUserSpace:
def test_uses_server_asserted_user(self):
from plugins.memory.openviking import _resolve_user_space
class _Client:
def get(self, path):
assert path == "/api/v1/system/status"
return {"status": "ok", "result": {"user": "alice"}}
assert _resolve_user_space(_Client()) == "alice"
def test_probe_failure_returns_unresolved(self):
from plugins.memory.openviking import _resolve_user_space
class _Client:
def get(self, path):
raise RuntimeError("probe down")
assert _resolve_user_space(_Client()) is None
def test_missing_user_field_returns_unresolved(self):
from plugins.memory.openviking import _resolve_user_space
class _Client:
def get(self, path):
return {"status": "ok", "result": {}}
assert _resolve_user_space(_Client()) is None
class TestOpenVikingMemoryUriBuilderUserSpace:
def test_confirmed_user_space_flows_into_uri(self):
class _Client:
def get(self, path):
assert path == "/api/v1/system/status"
return {"status": "ok", "result": {"user": "alice"}}
p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider)
p._agent = "coder"
p._user = "default"
p._client = _Client()
p._user_space_cache = None
uri = p._build_memory_uri("preferences")
assert uri.startswith("viking://user/alice/peers/coder/memories/preferences/mem_")
assert uri.endswith(".md")
def test_transient_probe_failure_is_not_cached(self):
class _Client:
def __init__(self):
self.calls = 0
def get(self, path):
assert path == "/api/v1/system/status"
self.calls += 1
if self.calls == 1:
raise RuntimeError("temporary failure")
return {"status": "ok", "result": {"user": "alice"}}
p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider)
p._agent = "coder"
p._user = "fallback-user"
p._client = _Client()
p._user_space_cache = None
first = p._build_memory_uri("preferences")
second = p._build_memory_uri("preferences")
assert first.startswith("viking://user/fallback-user/peers/coder/")
assert second.startswith("viking://user/alice/peers/coder/")
assert p._client.calls == 2
def test_cache_is_bound_to_the_client_that_asserted_the_user(self):
class _Client:
def __init__(self, user):
self.user = user
self.calls = 0
def get(self, path):
assert path == "/api/v1/system/status"
self.calls += 1
return {"status": "ok", "result": {"user": self.user}}
p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider)
p._agent = "coder"
p._user = "default"
p._user_space_cache = None
alice = _Client("alice")
bob = _Client("bob")
p._client = alice
assert "/user/alice/" in p._build_memory_uri("preferences")
p._client = bob
assert "/user/bob/" in p._build_memory_uri("preferences")
assert (alice.calls, bob.calls) == (1, 1)
def test_old_inflight_probe_cannot_replace_new_client_cache(self):
old_probe_started = threading.Event()
release_old_probe = threading.Event()
class _Client:
def __init__(self, user, wait=False):
self.user = user
self.wait = wait
def get(self, path):
assert path == "/api/v1/system/status"
if self.wait:
old_probe_started.set()
assert release_old_probe.wait(2.0)
return {"status": "ok", "result": {"user": self.user}}
p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider)
p._user = "default"
p._user_space_cache = None
alice = _Client("alice", wait=True)
bob = _Client("bob")
p._client = alice
old_result = []
worker = threading.Thread(target=lambda: old_result.append(p._user_space(alice)))
worker.start()
assert old_probe_started.wait(2.0)
p._client = bob
assert p._user_space() == "bob"
release_old_probe.set()
worker.join(timeout=2.0)
assert not worker.is_alive()
assert old_result == ["alice"]
assert p._user_space() == "bob"