"""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 {})) if path.endswith("/commit"): return { "result": { "status": "accepted", "task_id": "task-remember", "trace_id": "trace-remember", } } return {"status": "ok"} 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"] == "submitted" assert out["session_id"].startswith("hermes-remember-") assert out["session_uri"] == f"viking://user/default/sessions/{out['session_id']}" assert out["message_status"] == "accepted" assert out["extraction_status"] == "accepted" assert out["task_id"] == "task-remember" assert out["trace_id"] == "trace-remember" assert len(instances) == 2 session_id = out["session_id"] assert instances[1].posts == [ ( f"/api/v1/sessions/{session_id}/messages", { "role": "user", "parts": [{"type": "text", "text": "stable fact"}], }, ), ( f"/api/v1/sessions/{session_id}/commit", {"keep_recent_count": 0}, ), ] def test_remember_accepts_legacy_category_but_submits_raw_user_text(self, monkeypatch): posts = [] class _StubClient: def post(self, path, payload=None, **kwargs): posts.append((path, payload or {})) if path.endswith("/commit"): return {"result": {"status": "accepted", "task_id": "task-1"}} return {"status": "ok"} provider = OpenVikingMemoryProvider() provider._client = _StubClient() provider._agent = "hermes" monkeypatch.setattr(provider, "_ensure_client", lambda: provider._client) out = json.loads(provider._tool_remember({ "content": "stable fact", "category": "preference", })) session_id = out["session_id"] message_path, message = posts[0] assert message_path == f"/api/v1/sessions/{session_id}/messages" assert message["role"] == "user" assert message["parts"] == [{"type": "text", "text": "stable fact"}] assert "peer_id" not in message assert "category" not in openviking_plugin.REMEMBER_SCHEMA["parameters"]["properties"] assert posts[1] == ( f"/api/v1/sessions/{session_id}/commit", {"keep_recent_count": 0}, ) def test_remember_uses_a_distinct_one_shot_session_for_each_call(self, monkeypatch): posts = [] class _StubClient: def post(self, path, payload=None, **kwargs): posts.append((path, payload or {})) if path.endswith("/commit"): return {"result": {"status": "accepted"}} return {"status": "ok"} provider = OpenVikingMemoryProvider() provider._client = _StubClient() monkeypatch.setattr(provider, "_ensure_client", lambda: provider._client) first = json.loads(provider._tool_remember({"content": "first"})) second = json.loads(provider._tool_remember({"content": "second"})) assert first["session_id"] != second["session_id"] assert first["session_id"].startswith("hermes-remember-") assert second["session_id"].startswith("hermes-remember-") assert all("/api/v1/content/write" not in path for path, _ in posts) def test_remember_reports_unknown_message_submission_failure(self, monkeypatch): posts = [] class _StubClient: def post(self, path, payload=None, **kwargs): posts.append((path, payload or {})) raise TimeoutError("message timeout") provider = OpenVikingMemoryProvider() provider._client = _StubClient() monkeypatch.setattr(provider, "_ensure_client", lambda: provider._client) out = json.loads(provider._tool_remember({"content": "stable fact"})) assert out["error"].startswith("Memory message submission failed for session ") assert out["error"].endswith(": message timeout") assert out["failure_stage"] == "message" assert out["message_status"] == "unknown" assert out["session_uri"].endswith(f"/sessions/{out['session_id']}") assert out["recovery_command"] == f"ov session commit {out['session_id']}" assert "do not resubmit automatically" in out["recovery_note"] assert len(posts) == 1 assert posts[0][0].endswith("/messages") def test_remember_reports_commit_failure_with_recovery_command(self, monkeypatch): posts = [] class _StubClient: def post(self, path, payload=None, **kwargs): posts.append((path, payload or {})) if path.endswith("/commit"): raise RuntimeError("commit rejected") return {"status": "ok"} provider = OpenVikingMemoryProvider() provider._client = _StubClient() monkeypatch.setattr(provider, "_ensure_client", lambda: provider._client) out = json.loads(provider._tool_remember({"content": "stable fact"})) assert out["error"].startswith( "Memory message was accepted, but commit failed for session hermes-remember-" ) assert out["error"].endswith(": commit rejected") assert out["failure_stage"] == "commit" assert out["message_status"] == "accepted" assert out["session_uri"].endswith(f"/sessions/{out['session_id']}") assert out["recovery_command"] == f"ov session commit {out['session_id']}" assert "same OpenViking profile and credentials as Hermes" in out["recovery_note"] assert len(posts) == 2 assert posts[0][0].endswith("/messages") assert posts[1][0].endswith("/commit") 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"