mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
`_write_env_vars` in the OpenViking memory provider interpolates each
secret straight into a `KEY=VALUE` line, but the values only ever pass
through `_clean_config_value`, whose `value.strip()` trims surrounding
whitespace and leaves internal CR/LF intact. Because the file is strictly
line-oriented and is re-read via `read_text().splitlines()`, a value that
carries an embedded newline spills onto a second physical line, and the
tail is re-parsed as an independent `KEY=VALUE` entry on the next round
trip. A secret pasted with a trailing record (e.g. an `OPENVIKING_API_KEY`
copied with an extra line) therefore injects an arbitrary additional
variable into the persisted credentials file and silently corrupts it.
The fix neutralizes the line terminators at the single chokepoint where
values reach the file. A small `_env_line_safe` helper strips `\r`, `\n`,
and the NUL byte from each value, and both write sites in `_write_env_vars`
(the existing-key update branch and the appended-key branch) route through
it, so a value can only ever occupy the single line it is written on.
## What does this PR do?
Hardens the OpenViking memory provider's `.env` writer so a malformed or
pasted secret value can no longer break out of its `KEY=VALUE` line and
inject a rogue variable into the profile-scoped credentials file.
## Related Issue
N/A
## Type of Change
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `plugins/memory/openviking/__init__.py`: add `_env_line_safe()` which
removes `\r`, `\n`, and `\x00` from a value, and apply it to both the
updated-key and appended-key write branches in `_write_env_vars()`.
- `tests/plugins/memory/test_openviking_provider.py`: add two regression
tests covering a fresh write and an in-place key update with embedded
CR/LF, asserting no injected line survives the read-back.
## How to Test
1. Run the targeted tests:
`pytest tests/plugins/memory/test_openviking_provider.py -k env_writer -q`
2. Reverting the `_env_line_safe` sanitization makes
`test_openviking_env_writer_strips_embedded_newlines_in_values` and
`test_openviking_env_writer_strips_newlines_when_updating_existing_key`
fail with a rogue `INJECTED_KEY=`/`ROGUE=1` line appearing in the file,
confirming the tests pin the bug.
3. `ruff check plugins/memory/openviking/__init__.py` and
`python scripts/check-windows-footguns.py plugins/memory/openviking/__init__.py`
both pass.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix
- [x] I've run the relevant tests and they pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A
- [x] I've considered cross-platform impact (strips CR as well as LF) — done
- [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A
(cherry picked from commit f29dd2df84)
4332 lines
146 KiB
Python
4332 lines
146 KiB
Python
import json
|
|
import os
|
|
import stat
|
|
import time
|
|
import zipfile
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
import plugins.memory.openviking as openviking_module
|
|
from plugins.memory.openviking import (
|
|
OpenVikingMemoryProvider,
|
|
_DEFERRED_COMMIT_TIMEOUT,
|
|
_VikingClient,
|
|
)
|
|
|
|
|
|
def _clear_openviking_tenant_env(monkeypatch):
|
|
for name in ("OPENVIKING_ACCOUNT", "OPENVIKING_USER", "OPENVIKING_AGENT"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_openviking_home(tmp_path, monkeypatch):
|
|
home = tmp_path / "home"
|
|
monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: home))
|
|
|
|
|
|
def _clear_openviking_env(monkeypatch):
|
|
for key in (
|
|
"OPENVIKING_ENDPOINT",
|
|
"OPENVIKING_API_KEY",
|
|
"OPENVIKING_ACCOUNT",
|
|
"OPENVIKING_USER",
|
|
"OPENVIKING_AGENT",
|
|
"OPENVIKING_CLI_CONFIG_FILE",
|
|
"OPENVIKING_PROFILE_TOKEN_BUDGET",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
|
|
def _prompt_from_values(values: dict[str, str], *, forbidden: set[str] | None = None):
|
|
forbidden = forbidden or set()
|
|
|
|
def _prompt(label, default=None, secret=False):
|
|
if label in forbidden:
|
|
raise AssertionError(f"{label} should not be prompted")
|
|
return values.get(label, default or "")
|
|
|
|
return _prompt
|
|
|
|
|
|
def _allow_setup_validation(monkeypatch, *, root_access: bool = False):
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_reachability",
|
|
lambda endpoint: (True, ""),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_auth",
|
|
lambda values: (True, ""),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_root_access",
|
|
lambda values: (root_access, "" if root_access else "Requires role: root"),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_setup_values",
|
|
lambda values, *, require_api_key=False: (
|
|
True,
|
|
"",
|
|
"root" if root_access else ("user" if values.get("api_key") else None),
|
|
),
|
|
raising=False,
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
|
|
def test_openviking_env_writer_restricts_file_permissions(tmp_path):
|
|
env_path = tmp_path / ".env"
|
|
|
|
openviking_module._write_env_vars(env_path, {"OPENVIKING_API_KEY": "secret"})
|
|
|
|
assert stat.S_IMODE(env_path.stat().st_mode) == 0o600
|
|
|
|
|
|
def test_openviking_env_writer_strips_embedded_newlines_in_values(tmp_path):
|
|
# A secret pasted with an embedded CR/LF must not spill onto a new line,
|
|
# or the round-trip re-parses the tail as a separate KEY=VALUE entry and
|
|
# injects an arbitrary variable into the persisted credentials file.
|
|
env_path = tmp_path / ".env"
|
|
|
|
openviking_module._write_env_vars(
|
|
env_path,
|
|
{"OPENVIKING_API_KEY": "good\nINJECTED_KEY=attacker"},
|
|
)
|
|
|
|
lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
assert lines == ["OPENVIKING_API_KEY=goodINJECTED_KEY=attacker"]
|
|
# No injected line means a follow-up read sees no rogue key.
|
|
parsed = dict(line.split("=", 1) for line in lines if "=" in line)
|
|
assert set(parsed) == {"OPENVIKING_API_KEY"}
|
|
assert "INJECTED_KEY" not in parsed
|
|
|
|
|
|
def test_openviking_env_writer_strips_newlines_when_updating_existing_key(tmp_path):
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text("OPENVIKING_API_KEY=old\n", encoding="utf-8")
|
|
|
|
openviking_module._write_env_vars(
|
|
env_path,
|
|
{"OPENVIKING_API_KEY": "new\r\nROGUE=1"},
|
|
)
|
|
|
|
lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
assert lines == ["OPENVIKING_API_KEY=newROGUE=1"]
|
|
assert all(not line.startswith("ROGUE=") for line in lines)
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
|
|
def test_ovcli_config_writer_restricts_file_permissions(tmp_path):
|
|
config_path = tmp_path / "ovcli.conf"
|
|
|
|
openviking_module._write_ovcli_config(
|
|
config_path,
|
|
{"endpoint": "http://remote.example", "api_key": "secret"},
|
|
)
|
|
|
|
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
|
|
|
|
def test_secret_permission_restriction_logs_chmod_failure(tmp_path, monkeypatch, caplog):
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text("OPENVIKING_API_KEY=secret\n", encoding="utf-8")
|
|
|
|
def fail_chmod(self, mode):
|
|
raise OSError("read-only filesystem")
|
|
|
|
monkeypatch.setattr(type(env_path), "chmod", fail_chmod)
|
|
|
|
with caplog.at_level("DEBUG", logger=openviking_module.__name__):
|
|
openviking_module._restrict_secret_file_permissions(env_path)
|
|
|
|
assert "Could not restrict permissions" in caplog.text
|
|
assert "read-only filesystem" in caplog.text
|
|
|
|
|
|
def test_linked_ovcli_config_is_read_at_runtime(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
ovcli_path = tmp_path / "ovcli.conf"
|
|
ovcli_path.write_text(
|
|
json.dumps({
|
|
"url": "http://openviking-one.local",
|
|
"api_key": "key-one",
|
|
"account": "acct-one",
|
|
"user": "alice",
|
|
"agent_id": "agent-one",
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
provider_config = {"use_ovcli_config": True, "ovcli_config_path": str(ovcli_path)}
|
|
|
|
settings = openviking_module._resolve_connection_settings(provider_config)
|
|
|
|
assert settings == {
|
|
"endpoint": "http://openviking-one.local",
|
|
"api_key": "key-one",
|
|
"account": "",
|
|
"user": "",
|
|
"agent": "agent-one",
|
|
}
|
|
|
|
ovcli_path.write_text(
|
|
json.dumps({
|
|
"url": "http://openviking-two.local",
|
|
"api_key": "key-two",
|
|
"agent_id": "agent-two",
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
settings = openviking_module._resolve_connection_settings(provider_config)
|
|
|
|
assert settings == {
|
|
"endpoint": "http://openviking-two.local",
|
|
"api_key": "key-two",
|
|
"account": "",
|
|
"user": "",
|
|
"agent": "agent-two",
|
|
}
|
|
|
|
|
|
def test_openviking_env_overrides_linked_ovcli_config(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
ovcli_path = tmp_path / "ovcli.conf"
|
|
ovcli_path.write_text(
|
|
json.dumps({
|
|
"url": "http://openviking.local",
|
|
"api_key": "file-key",
|
|
"account": "file-account",
|
|
"user": "file-user",
|
|
"agent_id": "file-agent",
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://env.local")
|
|
monkeypatch.setenv("OPENVIKING_API_KEY", "env-key")
|
|
monkeypatch.setenv("OPENVIKING_ACCOUNT", "env-account")
|
|
monkeypatch.setenv("OPENVIKING_USER", "env-user")
|
|
monkeypatch.setenv("OPENVIKING_AGENT", "env-agent")
|
|
|
|
settings = openviking_module._resolve_connection_settings({
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(ovcli_path),
|
|
})
|
|
|
|
assert settings == {
|
|
"endpoint": "http://env.local",
|
|
"api_key": "env-key",
|
|
"account": "env-account",
|
|
"user": "env-user",
|
|
"agent": "env-agent",
|
|
}
|
|
|
|
|
|
def test_openviking_cli_config_env_overrides_saved_profile_path(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
saved_path = tmp_path / "ovcli.conf.saved"
|
|
env_path = tmp_path / "ovcli.conf.env"
|
|
saved_path.write_text(
|
|
json.dumps({"url": "http://saved.local", "api_key": "saved-key"}),
|
|
encoding="utf-8",
|
|
)
|
|
env_path.write_text(
|
|
json.dumps({"url": "http://env-profile.local", "api_key": "env-profile-key"}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(env_path))
|
|
|
|
settings = openviking_module._resolve_connection_settings({
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(saved_path),
|
|
})
|
|
|
|
assert settings["endpoint"] == "http://env-profile.local"
|
|
assert settings["api_key"] == "env-profile-key"
|
|
|
|
|
|
def test_connection_values_omit_stale_identity_for_user_key_with_root_key():
|
|
values = openviking_module._connection_values_from_ovcli({
|
|
"url": "https://openviking.example",
|
|
"api_key": "user-key",
|
|
"root_api_key": "root-key",
|
|
"account": "stale-account",
|
|
"user": "stale-user",
|
|
})
|
|
|
|
assert values["api_key"] == "user-key"
|
|
assert values["account"] == ""
|
|
assert values["user"] == ""
|
|
|
|
|
|
def test_discover_ovcli_profiles_lists_saved_profiles_without_active_label(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
openviking_home = tmp_path / ".openviking"
|
|
openviking_home.mkdir()
|
|
env_path = tmp_path / "custom-ovcli.conf"
|
|
env_path.write_text(json.dumps({"url": "http://env.local"}), encoding="utf-8")
|
|
(openviking_home / "ovcli.conf").write_text(
|
|
json.dumps({"url": "https://vps.example", "api_key": "secret"}),
|
|
encoding="utf-8",
|
|
)
|
|
(openviking_home / "ovcli.conf.VPS").write_text(
|
|
json.dumps({"url": "https://vps.example", "api_key": "secret"}),
|
|
encoding="utf-8",
|
|
)
|
|
(openviking_home / "ovcli.conf.bak").write_text(
|
|
json.dumps({"url": "http://backup.local"}),
|
|
encoding="utf-8",
|
|
)
|
|
(openviking_home / "ovcli.conf.bad").write_text("{", encoding="utf-8")
|
|
monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(env_path))
|
|
monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path))
|
|
|
|
profiles = openviking_module._discover_ovcli_profiles()
|
|
|
|
assert [(profile.source, profile.name, profile.path) for profile in profiles] == [
|
|
("env", "OPENVIKING_CLI_CONFIG_FILE", env_path),
|
|
("saved", "VPS", openviking_home / "ovcli.conf.VPS"),
|
|
]
|
|
assert profiles[1].is_active is True
|
|
assert openviking_module._profile_display_name(profiles[1]) == "VPS"
|
|
assert "active" not in openviking_module._profile_description(profiles[1]).lower()
|
|
|
|
|
|
def test_link_ovcli_profile_removes_stale_inline_config(tmp_path):
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text("OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n", encoding="utf-8")
|
|
config = {"memory": {}}
|
|
provider_config = {
|
|
"use_ovcli_config": False,
|
|
"endpoint": "http://stale.local",
|
|
"api_key": "stale-key",
|
|
"account": "default",
|
|
"user": "default",
|
|
"agent": "stale-agent",
|
|
"api_key_type": "root",
|
|
}
|
|
ovcli_path = tmp_path / "ovcli.conf.VPS_ROOT"
|
|
|
|
openviking_module._link_ovcli_profile(
|
|
config=config,
|
|
provider_config=provider_config,
|
|
env_path=env_path,
|
|
ovcli_path=ovcli_path,
|
|
)
|
|
|
|
assert config["memory"]["openviking"] == {
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(ovcli_path),
|
|
}
|
|
assert "OPENVIKING_ENDPOINT" not in env_path.read_text(encoding="utf-8")
|
|
assert "OTHER_KEY=keep" in env_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_post_setup_existing_profile_picker_validates_and_links_saved_profile(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
env_path = hermes_home / ".env"
|
|
env_path.write_text("OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n", encoding="utf-8")
|
|
openviking_home = tmp_path / ".openviking"
|
|
openviking_home.mkdir()
|
|
active_path = openviking_home / "ovcli.conf"
|
|
saved_path = openviking_home / "ovcli.conf.VPS"
|
|
active_path.write_text(json.dumps({"url": "http://active.local"}), encoding="utf-8")
|
|
saved_path.write_text(
|
|
json.dumps({"url": "https://vps.example", "api_key": "user-key"}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path))
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
validate_calls = []
|
|
|
|
def validate_values(values, *, require_api_key=False):
|
|
validate_calls.append(dict(values))
|
|
return True, "", "user"
|
|
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_setup_values",
|
|
validate_values,
|
|
raising=False,
|
|
)
|
|
choices = iter([0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
assert validate_calls == [{
|
|
"endpoint": "https://vps.example",
|
|
"api_key": "user-key",
|
|
"root_api_key": "",
|
|
"account": "",
|
|
"user": "",
|
|
"agent": "",
|
|
}]
|
|
assert config["memory"]["provider"] == "openviking"
|
|
assert config["memory"]["openviking"] == {
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(saved_path),
|
|
}
|
|
env_text = env_path.read_text(encoding="utf-8")
|
|
assert "OPENVIKING_" not in env_text
|
|
assert "OTHER_KEY=keep" in env_text
|
|
|
|
|
|
def test_post_setup_create_remote_user_profile_can_mirror_to_openviking_store(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setattr(openviking_module.Path, "home", staticmethod(lambda: tmp_path))
|
|
_allow_setup_validation(monkeypatch)
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
choices = iter([1, 0, 1])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
monkeypatch.setattr(
|
|
memory_setup,
|
|
"_prompt",
|
|
_prompt_from_values({
|
|
"OpenViking server URL": "https://openviking.example",
|
|
"OpenViking user API key": "user-secret",
|
|
"Hermes peer ID in OpenViking": "hermes",
|
|
"OpenViking profile name": "VPS",
|
|
}),
|
|
)
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
mirrored_path = tmp_path / ".openviking" / "ovcli.conf.VPS"
|
|
assert mirrored_path.exists()
|
|
assert json.loads(mirrored_path.read_text(encoding="utf-8")) == {
|
|
"url": "https://openviking.example",
|
|
"api_key": "user-secret",
|
|
"actor_peer_id": "hermes",
|
|
}
|
|
assert config["memory"]["provider"] == "openviking"
|
|
assert config["memory"]["openviking"] == {
|
|
"use_ovcli_config": True,
|
|
"ovcli_config_path": str(mirrored_path),
|
|
}
|
|
env_path = hermes_home / ".env"
|
|
if env_path.exists():
|
|
assert "OPENVIKING_" not in env_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_post_setup_create_remote_user_can_keep_hermes_only(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
_allow_setup_validation(monkeypatch)
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
choices = iter([1, 0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
monkeypatch.setattr(
|
|
memory_setup,
|
|
"_prompt",
|
|
_prompt_from_values({
|
|
"OpenViking server URL": "https://openviking.example",
|
|
"OpenViking user API key": "user-secret",
|
|
"Hermes peer ID in OpenViking": "agent",
|
|
}),
|
|
)
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
assert config["memory"]["provider"] == "openviking"
|
|
assert config["memory"]["openviking"] == {"use_ovcli_config": False}
|
|
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
|
|
assert "OPENVIKING_ENDPOINT=https://openviking.example" in env_text
|
|
assert "OPENVIKING_API_KEY=user-secret" in env_text
|
|
assert "OPENVIKING_AGENT=agent" in env_text
|
|
assert not (tmp_path / "home" / ".openviking").exists()
|
|
|
|
|
|
def test_post_setup_create_openviking_service_validates_after_api_key(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
validation_calls = []
|
|
|
|
def validate_values(values, *, require_api_key=False):
|
|
validation_calls.append((dict(values), require_api_key))
|
|
return True, "", "user"
|
|
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_validate_openviking_reachability",
|
|
MagicMock(side_effect=AssertionError("service setup validates only after API key entry")),
|
|
)
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values)
|
|
choices = iter([0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
monkeypatch.setattr(
|
|
memory_setup,
|
|
"_prompt",
|
|
_prompt_from_values(
|
|
{
|
|
"OpenViking API key": "service-secret",
|
|
"Hermes peer ID in OpenViking": "agent",
|
|
},
|
|
forbidden={"OpenViking server URL", "OpenViking user API key", "OpenViking root API key"},
|
|
),
|
|
)
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
assert validation_calls == [(
|
|
{
|
|
"endpoint": "https://api.vikingdb.cn-beijing.volces.com/openviking",
|
|
"api_key": "service-secret",
|
|
"root_api_key": "",
|
|
"account": "",
|
|
"user": "",
|
|
"agent": "agent",
|
|
"api_key_type": "user",
|
|
},
|
|
True,
|
|
)]
|
|
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
|
|
assert "OPENVIKING_ENDPOINT=https://api.vikingdb.cn-beijing.volces.com/openviking" in env_text
|
|
assert "OPENVIKING_API_KEY=service-secret" in env_text
|
|
assert "OPENVIKING_AGENT=agent" in env_text
|
|
|
|
|
|
def test_post_setup_remote_blank_api_key_cancels_without_saving(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, ""))
|
|
|
|
from hermes_cli import config as hermes_config
|
|
from hermes_cli import memory_setup
|
|
|
|
save_config = MagicMock()
|
|
monkeypatch.setattr(hermes_config, "save_config", save_config)
|
|
choices = iter([1, 0, 1])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
monkeypatch.setattr(
|
|
memory_setup,
|
|
"_prompt",
|
|
_prompt_from_values({
|
|
"OpenViking server URL": "https://openviking.example",
|
|
"OpenViking user API key": "",
|
|
}),
|
|
)
|
|
config = {"memory": {"provider": "builtin"}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
save_config.assert_not_called()
|
|
assert config == {"memory": {"provider": "builtin"}}
|
|
assert not (hermes_home / ".env").exists()
|
|
|
|
|
|
def test_post_setup_user_key_path_can_route_detected_root_key_to_root_setup(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
def validate_values(values, *, require_api_key=False):
|
|
assert values["api_key"] == "root-secret"
|
|
return True, "", "root"
|
|
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, ""))
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values)
|
|
choices = iter([1, 0, 0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
prompt_events = []
|
|
|
|
def fake_prompt(label, default=None, secret=False):
|
|
if label == "OpenViking root API key":
|
|
raise AssertionError("OpenViking root API key should not be re-prompted")
|
|
prompt_events.append(label)
|
|
values = {
|
|
"OpenViking server URL": "https://openviking.example",
|
|
"OpenViking user API key": "root-secret",
|
|
"OpenViking account": "acct",
|
|
"OpenViking user": "alice",
|
|
"Hermes peer ID in OpenViking": "agent",
|
|
}
|
|
return values.get(label, default or "")
|
|
|
|
monkeypatch.setattr(memory_setup, "_prompt", fake_prompt)
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
assert prompt_events.count("Hermes peer ID in OpenViking") == 1
|
|
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
|
|
assert "OPENVIKING_API_KEY=root-secret" in env_text
|
|
assert "OPENVIKING_ACCOUNT=acct" in env_text
|
|
assert "OPENVIKING_USER=alice" in env_text
|
|
assert "OPENVIKING_AGENT=agent" in env_text
|
|
|
|
|
|
def test_post_setup_root_key_path_can_route_detected_user_key_to_user_setup(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
def validate_values(values, *, require_api_key=False):
|
|
assert values["api_key"] == "user-secret"
|
|
return True, "", "user"
|
|
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, ""))
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values)
|
|
choices = iter([1, 1, 0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
monkeypatch.setattr(
|
|
memory_setup,
|
|
"_prompt",
|
|
_prompt_from_values(
|
|
{
|
|
"OpenViking server URL": "https://openviking.example",
|
|
"OpenViking root API key": "user-secret",
|
|
"Hermes peer ID in OpenViking": "agent",
|
|
},
|
|
forbidden={"OpenViking user API key", "OpenViking account", "OpenViking user"},
|
|
),
|
|
)
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
|
|
assert "OPENVIKING_API_KEY=user-secret" in env_text
|
|
assert "OPENVIKING_AGENT=agent" in env_text
|
|
assert "OPENVIKING_ACCOUNT" not in env_text
|
|
assert "OPENVIKING_USER" not in env_text
|
|
|
|
|
|
def test_manual_root_key_flow_prints_validation_progress(monkeypatch, capsys):
|
|
_clear_openviking_env(monkeypatch)
|
|
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", lambda endpoint: (True, ""))
|
|
|
|
validate_calls = []
|
|
|
|
def validate_values(values, *, require_api_key=False):
|
|
validate_calls.append(dict(values))
|
|
return True, "", "root"
|
|
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", validate_values)
|
|
choices = iter([1])
|
|
|
|
values = openviking_module._prompt_manual_connection_values(
|
|
_prompt_from_values({
|
|
"OpenViking server URL": "https://openviking.example",
|
|
"OpenViking root API key": "root-secret",
|
|
"OpenViking account": "acct",
|
|
"OpenViking user": "alice",
|
|
"Hermes peer ID in OpenViking": "agent",
|
|
}),
|
|
lambda *args, **kwargs: next(choices),
|
|
-1,
|
|
)
|
|
|
|
assert values["root_api_key"] == "root-secret"
|
|
assert len(validate_calls) == 2
|
|
output = capsys.readouterr().out
|
|
assert "Checking OpenViking server..." in output
|
|
assert "Validating OpenViking root API key..." in output
|
|
assert "Validating OpenViking API access..." in output
|
|
|
|
|
|
def test_start_local_openviking_server_uses_endpoint_host_and_port(monkeypatch):
|
|
popen_calls = []
|
|
|
|
def fake_popen(args, **kwargs):
|
|
popen_calls.append((args, kwargs))
|
|
return object()
|
|
|
|
monkeypatch.setattr(openviking_module.shutil, "which", lambda name: "/usr/local/bin/openviking-server")
|
|
monkeypatch.setattr(openviking_module.subprocess, "Popen", fake_popen)
|
|
|
|
started, message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934")
|
|
|
|
assert started is True
|
|
assert "127.0.0.1:1934" in message
|
|
args, kwargs = popen_calls[0]
|
|
assert args == ["/usr/local/bin/openviking-server", "--host", "127.0.0.1", "--port", "1934"]
|
|
assert kwargs["start_new_session"] is True
|
|
|
|
|
|
def test_start_local_openviking_server_writes_output_to_log(tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / "hermes"
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
popen_calls = []
|
|
|
|
class FakeProcess:
|
|
pass
|
|
|
|
def fake_popen(args, **kwargs):
|
|
popen_calls.append((args, kwargs))
|
|
assert kwargs["stdout"] is kwargs["stderr"]
|
|
assert kwargs["stdout"].name == str(hermes_home / "logs" / "openviking-server.log")
|
|
assert not kwargs["stdout"].closed
|
|
return FakeProcess()
|
|
|
|
monkeypatch.setattr(openviking_module.shutil, "which", lambda name: "/usr/local/bin/openviking-server")
|
|
monkeypatch.setattr(openviking_module.subprocess, "Popen", fake_popen)
|
|
|
|
started, message = openviking_module._start_local_openviking_server("http://127.0.0.1:1934")
|
|
|
|
assert started is True
|
|
assert str(hermes_home / "logs" / "openviking-server.log") in message
|
|
assert popen_calls
|
|
|
|
|
|
def test_https_local_endpoint_is_not_runtime_autostart_eligible(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://localhost:1934")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://localhost:1934"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
MagicMock(side_effect=AssertionError("https localhost endpoint should not auto-start")),
|
|
)
|
|
|
|
warnings = []
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1", platform="cli", warning_callback=warnings.append)
|
|
|
|
assert provider._client is None
|
|
assert warnings == [
|
|
"Remote OpenViking server at https://localhost:1934 is not reachable; "
|
|
"OpenViking memory disabled for this Hermes run. "
|
|
"Check the configured endpoint and network connectivity."
|
|
]
|
|
|
|
|
|
def test_runtime_does_not_autostart_when_local_server_reports_unhealthy(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://localhost:1934"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
def health_payload(self):
|
|
return {"healthy": False}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
MagicMock(side_effect=AssertionError("responding unhealthy server should not auto-start another process")),
|
|
)
|
|
|
|
warnings = []
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1", platform="cli", warning_callback=warnings.append)
|
|
|
|
assert provider._client is None
|
|
assert warnings == [
|
|
"OpenViking server at http://localhost:1934 responded but reported unhealthy status. "
|
|
"OpenViking memory disabled for this Hermes run."
|
|
]
|
|
|
|
|
|
def test_handle_unreachable_endpoint_does_not_wait_when_autostart_command_missing(monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: (False, "openviking-server was not found on PATH."),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
MagicMock(side_effect=AssertionError("should not wait when server did not start")),
|
|
)
|
|
|
|
result = openviking_module._handle_unreachable_endpoint(
|
|
"http://127.0.0.1:1934",
|
|
"OpenViking server is not reachable.",
|
|
lambda *args, **kwargs: 0,
|
|
-1,
|
|
)
|
|
|
|
assert result is False
|
|
output = capsys.readouterr().out
|
|
assert "openviking-server was not found on PATH." in output
|
|
assert "did not become reachable" not in output
|
|
|
|
|
|
def test_handle_unreachable_endpoint_waits_long_enough_after_autostart(monkeypatch, capsys):
|
|
wait_calls = []
|
|
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: (True, "Started openviking-server on 127.0.0.1:1934 in the background."),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
lambda endpoint, *, timeout_seconds=0: wait_calls.append((endpoint, timeout_seconds)) or True,
|
|
)
|
|
|
|
result = openviking_module._handle_unreachable_endpoint(
|
|
"http://127.0.0.1:1934",
|
|
"OpenViking server is not reachable.",
|
|
lambda *args, **kwargs: 0,
|
|
-1,
|
|
)
|
|
|
|
assert result is True
|
|
assert wait_calls == [("http://127.0.0.1:1934", 60.0)]
|
|
output = capsys.readouterr().out
|
|
assert "Waiting for OpenViking server to become reachable..." in output
|
|
|
|
|
|
def test_manual_setup_does_not_offer_autostart_when_local_server_is_unhealthy(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://localhost:1933"
|
|
|
|
def health_payload(self):
|
|
return {"healthy": False}
|
|
|
|
select_calls = []
|
|
|
|
def select(title, options, **kwargs):
|
|
select_calls.append((title, options))
|
|
assert all(label != "Start local OpenViking" for label, _description in options)
|
|
return 1
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
MagicMock(side_effect=AssertionError("unhealthy local server should not offer auto-start")),
|
|
)
|
|
|
|
result = openviking_module._prompt_manual_connection_values(
|
|
_prompt_from_values({"OpenViking server URL": "localhost"}),
|
|
select,
|
|
-1,
|
|
)
|
|
|
|
assert result is openviking_module._SETUP_CANCELLED
|
|
assert select_calls == [(
|
|
" OpenViking server unhealthy",
|
|
[
|
|
("Retry", "try this step again"),
|
|
("Cancel setup", "no changes saved"),
|
|
],
|
|
)]
|
|
|
|
|
|
def test_initialize_autostarts_local_openviking_in_background_when_runtime_health_fails(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://127.0.0.1:1934")
|
|
health_calls = []
|
|
start_calls = []
|
|
waiter_calls = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://127.0.0.1:1934"
|
|
|
|
def health(self):
|
|
health_calls.append("health")
|
|
return False
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: start_calls.append(endpoint) or (True, "started"),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
MagicMock(side_effect=AssertionError("runtime init should not wait synchronously")),
|
|
)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
monkeypatch.setattr(
|
|
provider,
|
|
"_start_runtime_openviking_waiter",
|
|
lambda **kwargs: waiter_calls.append(kwargs),
|
|
raising=False,
|
|
)
|
|
statuses = []
|
|
provider.initialize("session-1", platform="cli", status_callback=statuses.append)
|
|
|
|
assert provider._client is None
|
|
assert health_calls == ["health"]
|
|
assert start_calls == ["http://127.0.0.1:1934"]
|
|
assert len(waiter_calls) == 1
|
|
assert waiter_calls[0]["status_callback"] == statuses.append
|
|
assert any("starting in the background" in message for message in statuses)
|
|
|
|
|
|
def test_runtime_openviking_waiter_attaches_client_after_health_recovers(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
wait_calls = []
|
|
|
|
class FakeVikingClient:
|
|
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
|
|
|
|
def health(self):
|
|
return True
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
lambda endpoint, **kwargs: wait_calls.append((endpoint, kwargs)) or True,
|
|
)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._endpoint = "http://127.0.0.1:1934"
|
|
provider._api_key = "secret"
|
|
provider._account = "acct"
|
|
provider._user = "alice"
|
|
provider._agent = "hermes"
|
|
statuses = []
|
|
|
|
provider._finish_runtime_openviking_start(
|
|
status_callback=statuses.append,
|
|
warning_callback=None,
|
|
)
|
|
|
|
assert provider._client is not None
|
|
assert provider._client.endpoint == "http://127.0.0.1:1934"
|
|
assert provider._client.api_key == "secret"
|
|
assert wait_calls == [(
|
|
"http://127.0.0.1:1934",
|
|
{"timeout_seconds": openviking_module._LOCAL_OPENVIKING_AUTOSTART_TIMEOUT},
|
|
)]
|
|
assert any("OpenViking memory is active" in message for message in statuses)
|
|
|
|
|
|
def test_runtime_openviking_waiter_warns_when_background_start_times_out(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
lambda endpoint, **kwargs: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_VikingClient",
|
|
MagicMock(side_effect=AssertionError("client should not be rebuilt before health recovers")),
|
|
)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._endpoint = "http://127.0.0.1:1934"
|
|
warnings = []
|
|
|
|
provider._finish_runtime_openviking_start(
|
|
status_callback=None,
|
|
warning_callback=warnings.append,
|
|
)
|
|
|
|
assert provider._client is None
|
|
assert warnings == [
|
|
"Local OpenViking server at http://127.0.0.1:1934 is not reachable. "
|
|
"Tried to start openviking-server, but it did not become reachable "
|
|
"within 60 seconds. OpenViking memory disabled for this Hermes run."
|
|
]
|
|
|
|
|
|
def test_initialize_does_not_autostart_remote_openviking(monkeypatch, caplog):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "https://openviking.example")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://openviking.example"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
MagicMock(side_effect=AssertionError("remote endpoint should not auto-start")),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
MagicMock(side_effect=AssertionError("remote endpoint should not wait")),
|
|
)
|
|
|
|
with caplog.at_level("WARNING", logger=openviking_module.__name__):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1")
|
|
|
|
assert provider._client is None
|
|
assert "Remote OpenViking server at https://openviking.example is not reachable" in caplog.text
|
|
|
|
|
|
def test_initialize_warns_clearly_when_local_runtime_autostart_fails(monkeypatch, caplog):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://localhost:1934"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: (False, "openviking-server was not found on PATH."),
|
|
)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_wait_for_openviking_health",
|
|
MagicMock(side_effect=AssertionError("should not wait when server did not start")),
|
|
)
|
|
|
|
with caplog.at_level("WARNING", logger=openviking_module.__name__):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1")
|
|
|
|
assert provider._client is None
|
|
assert "Local OpenViking server at http://localhost:1934 is not reachable" in caplog.text
|
|
assert "openviking-server was not found on PATH" in caplog.text
|
|
|
|
|
|
def test_initialize_emits_cli_warning_when_local_runtime_autostart_fails(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://localhost:1934"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
warnings = []
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: (False, "openviking-server was not found on PATH."),
|
|
)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1", platform="cli", warning_callback=warnings.append)
|
|
|
|
assert provider._client is None
|
|
assert warnings == [
|
|
"Local OpenViking server at http://localhost:1934 is not reachable. "
|
|
"openviking-server was not found on PATH. "
|
|
"OpenViking memory disabled for this Hermes run."
|
|
]
|
|
|
|
|
|
def test_initialize_does_not_emit_cli_warning_when_callback_absent(monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://localhost:1934")
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://localhost:1934"
|
|
|
|
def health(self):
|
|
return False
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
monkeypatch.setattr(
|
|
openviking_module,
|
|
"_start_local_openviking_server",
|
|
lambda endpoint: (False, "openviking-server was not found on PATH."),
|
|
)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider.initialize("session-1", platform="gateway")
|
|
|
|
assert provider._client is None
|
|
|
|
|
|
def test_post_setup_local_server_down_can_offer_autostart(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_setup_values", lambda values, *, require_api_key=False: (True, "", None))
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
reachability_calls = []
|
|
|
|
def validate_reachability(endpoint):
|
|
reachability_calls.append(endpoint)
|
|
return False, "OpenViking server is not reachable." if len(reachability_calls) == 1 else ""
|
|
|
|
started = []
|
|
monkeypatch.setattr(openviking_module, "_validate_openviking_reachability", validate_reachability)
|
|
monkeypatch.setattr(openviking_module, "_start_local_openviking_server", lambda endpoint: (started.append(endpoint) or True, "started"))
|
|
monkeypatch.setattr(openviking_module, "_wait_for_openviking_health", lambda endpoint, **kwargs: True)
|
|
choices = iter([1, 0, 0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
monkeypatch.setattr(
|
|
memory_setup,
|
|
"_prompt",
|
|
_prompt_from_values({
|
|
"OpenViking server URL": "localhost",
|
|
"Hermes peer ID in OpenViking": "agent",
|
|
}),
|
|
)
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
assert started == ["http://localhost:1933"]
|
|
assert reachability_calls == ["http://localhost:1933"]
|
|
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
|
|
assert "OPENVIKING_ENDPOINT=http://localhost:1933" in env_text
|
|
assert "OPENVIKING_API_KEY" not in env_text
|
|
|
|
|
|
def test_post_setup_invalid_env_profile_can_create_new_config(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
ovcli_path = tmp_path / "broken" / "ovcli.conf"
|
|
ovcli_path.parent.mkdir()
|
|
ovcli_path.write_text("{", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path))
|
|
_allow_setup_validation(monkeypatch)
|
|
|
|
from hermes_cli import memory_setup
|
|
|
|
choices = iter([1, 0, 0])
|
|
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(choices))
|
|
monkeypatch.setattr(
|
|
memory_setup,
|
|
"_prompt",
|
|
_prompt_from_values({
|
|
"OpenViking server URL": "https://openviking.example",
|
|
"OpenViking user API key": "user-secret",
|
|
"Hermes peer ID in OpenViking": "agent",
|
|
}),
|
|
)
|
|
config = {"memory": {}}
|
|
|
|
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
|
|
|
|
assert ovcli_path.read_text(encoding="utf-8") == "{"
|
|
assert config["memory"]["openviking"] == {"use_ovcli_config": False}
|
|
|
|
|
|
def test_tool_search_sorts_by_raw_score_across_buckets():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.post.return_value = {
|
|
"result": {
|
|
"memories": [
|
|
{"uri": "viking://memories/1", "score": 0.9003, "abstract": "memory result"},
|
|
],
|
|
"resources": [
|
|
{"uri": "viking://resources/1", "score": 0.9004, "abstract": "resource result"},
|
|
],
|
|
"skills": [
|
|
{"uri": "viking://skills/1", "score": 0.8999, "abstract": "skill result"},
|
|
],
|
|
"total": 3,
|
|
}
|
|
}
|
|
|
|
result = json.loads(provider._tool_search({"query": "ranking"}))
|
|
|
|
assert [entry["uri"] for entry in result["results"]] == [
|
|
"viking://resources/1",
|
|
"viking://memories/1",
|
|
"viking://skills/1",
|
|
]
|
|
assert [entry["score"] for entry in result["results"]] == [0.9, 0.9, 0.9]
|
|
assert result["total"] == 3
|
|
|
|
|
|
def test_tool_search_sorts_missing_raw_score_after_negative_scores():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.post.return_value = {
|
|
"result": {
|
|
"memories": [
|
|
{"uri": "viking://memories/missing", "abstract": "missing score"},
|
|
],
|
|
"resources": [
|
|
{"uri": "viking://resources/negative", "score": -0.25, "abstract": "negative score"},
|
|
],
|
|
"skills": [
|
|
{"uri": "viking://skills/positive", "score": 0.1, "abstract": "positive score"},
|
|
],
|
|
"total": 3,
|
|
}
|
|
}
|
|
|
|
result = json.loads(provider._tool_search({"query": "ranking"}))
|
|
|
|
assert [entry["uri"] for entry in result["results"]] == [
|
|
"viking://skills/positive",
|
|
"viking://memories/missing",
|
|
"viking://resources/negative",
|
|
]
|
|
assert [entry["score"] for entry in result["results"]] == [0.1, 0.0, -0.25]
|
|
assert result["total"] == 3
|
|
|
|
|
|
def test_tool_search_sends_limit_not_legacy_top_k():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.post.return_value = {
|
|
"result": {"memories": [], "resources": [], "skills": [], "total": 0}
|
|
}
|
|
|
|
provider._tool_search({"query": "session switch", "limit": 7})
|
|
|
|
provider._client.post.assert_called_once()
|
|
payload = provider._client.post.call_args.args[1]
|
|
assert payload["limit"] == 7
|
|
assert "top_k" not in payload
|
|
assert "mode" not in payload
|
|
|
|
|
|
def test_tool_search_uses_find_for_normal_search():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.post.return_value = {
|
|
"result": {"memories": [], "resources": [], "skills": [], "total": 0}
|
|
}
|
|
|
|
provider._tool_search({"query": "simple lookup", "mode": "fast"})
|
|
|
|
provider._client.post.assert_called_once_with("/api/v1/search/find", {
|
|
"query": "simple lookup",
|
|
})
|
|
assert "mode" not in provider._client.post.call_args.args[1]
|
|
|
|
|
|
def test_tool_search_uses_session_search_for_deep_search():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._session_id = "session-123"
|
|
provider._client.post.return_value = {
|
|
"result": {"memories": [], "resources": [], "skills": [], "total": 0}
|
|
}
|
|
|
|
provider._tool_search({"query": "connect facts", "mode": "deep"})
|
|
|
|
provider._client.post.assert_called_once_with("/api/v1/search/search", {
|
|
"query": "connect facts",
|
|
"session_id": "session-123",
|
|
})
|
|
assert "mode" not in provider._client.post.call_args.args[1]
|
|
|
|
|
|
def test_tool_add_resource_uploads_existing_local_file(tmp_path):
|
|
sample = tmp_path / "sample.md"
|
|
sample.write_text("# Local resource\n", encoding="utf-8")
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.upload_temp_file.return_value = "upload_sample.md"
|
|
provider._client.post.return_value = {
|
|
"status": "ok",
|
|
"result": {"root_uri": "viking://resources/sample"},
|
|
}
|
|
|
|
result = json.loads(provider._tool_add_resource({
|
|
"url": str(sample),
|
|
"reason": "local test",
|
|
"wait": True,
|
|
}))
|
|
|
|
provider._client.upload_temp_file.assert_called_once_with(sample)
|
|
provider._client.post.assert_called_once_with("/api/v1/resources", {
|
|
"reason": "local test",
|
|
"wait": True,
|
|
"source_name": "sample.md",
|
|
"temp_file_id": "upload_sample.md",
|
|
})
|
|
assert result["status"] == "added"
|
|
assert result["root_uri"] == "viking://resources/sample"
|
|
|
|
|
|
def test_tool_add_resource_uploads_file_uri(tmp_path):
|
|
sample = tmp_path / "sample.md"
|
|
sample.write_text("# Local resource\n", encoding="utf-8")
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.upload_temp_file.return_value = "upload_sample.md"
|
|
provider._client.post.return_value = {
|
|
"status": "ok",
|
|
"result": {"root_uri": "viking://resources/sample"},
|
|
}
|
|
|
|
result = json.loads(provider._tool_add_resource({
|
|
"url": sample.as_uri(),
|
|
"reason": "file uri test",
|
|
}))
|
|
|
|
provider._client.upload_temp_file.assert_called_once_with(sample)
|
|
provider._client.post.assert_called_once_with("/api/v1/resources", {
|
|
"reason": "file uri test",
|
|
"source_name": "sample.md",
|
|
"temp_file_id": "upload_sample.md",
|
|
})
|
|
assert result["status"] == "added"
|
|
assert result["root_uri"] == "viking://resources/sample"
|
|
|
|
|
|
def test_tool_add_resource_rejects_hermes_credential_file_upload(tmp_path, monkeypatch):
|
|
import agent.file_safety as fs
|
|
|
|
hermes_home = tmp_path / "hermes_home"
|
|
hermes_home.mkdir()
|
|
auth_json = hermes_home / "auth.json"
|
|
auth_json.write_text('{"OPENROUTER_API_KEY":"sk-test-secret"}', encoding="utf-8")
|
|
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
|
|
result = json.loads(provider._tool_add_resource({"url": str(auth_json)}))
|
|
|
|
assert "error" in result
|
|
assert "credential store" in result["error"]
|
|
provider._client.upload_temp_file.assert_not_called()
|
|
provider._client.post.assert_not_called()
|
|
|
|
|
|
def test_tool_add_resource_uploads_existing_local_directory_and_cleans_zip(tmp_path):
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "guide.md").write_text("# Guide\n", encoding="utf-8")
|
|
nested = docs / "nested"
|
|
nested.mkdir()
|
|
(nested / "api.md").write_text("# API\n", encoding="utf-8")
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
uploaded_paths = []
|
|
provider._client.upload_temp_file.side_effect = (
|
|
lambda path: uploaded_paths.append(path) or "upload_docs.zip"
|
|
)
|
|
provider._client.post.return_value = {
|
|
"status": "ok",
|
|
"result": {"root_uri": "viking://resources/docs"},
|
|
}
|
|
|
|
result = json.loads(provider._tool_add_resource({
|
|
"url": str(docs),
|
|
"reason": "directory test",
|
|
"wait": True,
|
|
}))
|
|
|
|
assert uploaded_paths
|
|
assert uploaded_paths[0].suffix == ".zip"
|
|
assert not uploaded_paths[0].exists()
|
|
provider._client.post.assert_called_once_with("/api/v1/resources", {
|
|
"reason": "directory test",
|
|
"wait": True,
|
|
"source_name": "docs",
|
|
"temp_file_id": "upload_docs.zip",
|
|
})
|
|
assert result["status"] == "added"
|
|
assert result["root_uri"] == "viking://resources/docs"
|
|
|
|
|
|
def test_tool_add_resource_directory_zip_skips_symlink_escape(tmp_path):
|
|
secret = tmp_path / "outside-secret.txt"
|
|
secret.write_text("do not upload\n", encoding="utf-8")
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "guide.md").write_text("# Guide\n", encoding="utf-8")
|
|
link = docs / "leak.txt"
|
|
try:
|
|
link.symlink_to(secret)
|
|
except OSError as exc:
|
|
pytest.skip(f"symlinks unavailable in test environment: {exc}")
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
archive_entries = {}
|
|
|
|
def inspect_upload(path):
|
|
with zipfile.ZipFile(path) as archive:
|
|
archive_entries["names"] = archive.namelist()
|
|
archive_entries["payloads"] = {
|
|
name: archive.read(name)
|
|
for name in archive.namelist()
|
|
}
|
|
return "upload_docs.zip"
|
|
|
|
provider._client.upload_temp_file.side_effect = inspect_upload
|
|
provider._client.post.return_value = {
|
|
"status": "ok",
|
|
"result": {"root_uri": "viking://resources/docs"},
|
|
}
|
|
|
|
json.loads(provider._tool_add_resource({"url": str(docs)}))
|
|
|
|
assert archive_entries["names"] == ["guide.md"]
|
|
assert b"do not upload" not in b"".join(archive_entries["payloads"].values())
|
|
|
|
|
|
def test_tool_add_resource_directory_zip_skips_hermes_credential_files(tmp_path, monkeypatch):
|
|
import agent.file_safety as fs
|
|
|
|
hermes_home = tmp_path / "hermes_home"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "guide.md").write_text("# Guide\n", encoding="utf-8")
|
|
(hermes_home / "auth.json").write_text(
|
|
'{"OPENROUTER_API_KEY":"sk-test-secret"}',
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
archive_entries = {}
|
|
|
|
def inspect_upload(path):
|
|
with zipfile.ZipFile(path) as archive:
|
|
archive_entries["names"] = archive.namelist()
|
|
archive_entries["payloads"] = {
|
|
name: archive.read(name)
|
|
for name in archive.namelist()
|
|
}
|
|
return "upload_hermes_home.zip"
|
|
|
|
provider._client.upload_temp_file.side_effect = inspect_upload
|
|
provider._client.post.return_value = {
|
|
"status": "ok",
|
|
"result": {"root_uri": "viking://resources/hermes_home"},
|
|
}
|
|
|
|
result = json.loads(provider._tool_add_resource({"url": str(hermes_home)}))
|
|
|
|
assert result["status"] == "added"
|
|
assert archive_entries["names"] == ["guide.md"]
|
|
assert b"sk-test-secret" not in b"".join(archive_entries["payloads"].values())
|
|
|
|
|
|
def test_tool_add_resource_cleans_local_directory_zip_when_add_fails(tmp_path):
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "guide.md").write_text("# Guide\n", encoding="utf-8")
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
uploaded_paths = []
|
|
provider._client.upload_temp_file.side_effect = (
|
|
lambda path: uploaded_paths.append(path) or "upload_docs.zip"
|
|
)
|
|
provider._client.post.side_effect = RuntimeError("add failed")
|
|
|
|
with pytest.raises(RuntimeError, match="add failed"):
|
|
provider._tool_add_resource({"url": str(docs)})
|
|
|
|
assert uploaded_paths
|
|
assert not uploaded_paths[0].exists()
|
|
|
|
|
|
def test_tool_add_resource_cleans_local_directory_zip_when_upload_fails(tmp_path):
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "guide.md").write_text("# Guide\n", encoding="utf-8")
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
uploaded_paths = []
|
|
|
|
def fail_upload(path):
|
|
uploaded_paths.append(path)
|
|
raise RuntimeError("upload failed")
|
|
|
|
provider._client.upload_temp_file.side_effect = fail_upload
|
|
|
|
with pytest.raises(RuntimeError, match="upload failed"):
|
|
provider._tool_add_resource({"url": str(docs)})
|
|
|
|
assert uploaded_paths
|
|
assert not uploaded_paths[0].exists()
|
|
provider._client.post.assert_not_called()
|
|
|
|
|
|
def test_tool_add_resource_rejects_missing_local_path(tmp_path):
|
|
missing = tmp_path / "missing.md"
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
|
|
result = json.loads(provider._tool_add_resource({"url": str(missing)}))
|
|
|
|
assert result["error"] == f"Local resource path does not exist: {missing}"
|
|
provider._client.upload_temp_file.assert_not_called()
|
|
provider._client.post.assert_not_called()
|
|
|
|
|
|
def test_tool_add_resource_sends_remote_url_as_path():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.post.return_value = {
|
|
"status": "ok",
|
|
"result": {"root_uri": "viking://resources/remote"},
|
|
}
|
|
|
|
provider._tool_add_resource({"url": "https://example.com/doc.md"})
|
|
|
|
provider._client.upload_temp_file.assert_not_called()
|
|
provider._client.post.assert_called_once_with("/api/v1/resources", {
|
|
"path": "https://example.com/doc.md",
|
|
})
|
|
|
|
|
|
@pytest.mark.parametrize("url", [
|
|
"git@github.com:org/repo.git",
|
|
"git@ssh.dev.azure.com:v3/org/project/repo",
|
|
"ssh://git@github.com/org/repo.git",
|
|
"git://github.com/org/repo.git",
|
|
])
|
|
def test_tool_add_resource_sends_git_remote_sources_as_path(url):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.post.return_value = {
|
|
"status": "ok",
|
|
"result": {"root_uri": "viking://resources/repo"},
|
|
}
|
|
|
|
provider._tool_add_resource({"url": url})
|
|
|
|
provider._client.upload_temp_file.assert_not_called()
|
|
provider._client.post.assert_called_once_with("/api/v1/resources", {
|
|
"path": url,
|
|
})
|
|
|
|
|
|
def test_get_tool_schemas_omits_profile_and_keeps_narrow_forget_tools():
|
|
provider = OpenVikingMemoryProvider()
|
|
|
|
names = [schema["name"] for schema in provider.get_tool_schemas()]
|
|
|
|
assert "viking_profile" not in names
|
|
assert "viking_forget" in names
|
|
|
|
|
|
def test_handle_tool_call_profile_returns_unknown_tool():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
|
|
result = json.loads(provider.handle_tool_call("viking_profile", {}))
|
|
|
|
assert result["error"] == "Unknown tool: viking_profile"
|
|
provider._client.get.assert_not_called()
|
|
|
|
|
|
def test_system_prompt_block_omits_removed_profile_tool_guidance():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.get.return_value = {"result": [{"name": "memories"}]}
|
|
|
|
prompt = provider.system_prompt_block()
|
|
|
|
assert "viking_profile" not in prompt
|
|
assert "viking_search" in prompt
|
|
|
|
|
|
def test_handle_tool_call_forget_deletes_exact_memory_file_uri():
|
|
uri = "viking://user/peers/hermes/memories/preferences/mem_abc123.md"
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.delete.return_value = {
|
|
"status": "ok",
|
|
"result": {"uri": uri, "estimated_deleted_count": 1},
|
|
}
|
|
|
|
result = json.loads(provider.handle_tool_call("viking_forget", {"uri": uri}))
|
|
|
|
provider._client.delete.assert_called_once_with(
|
|
"/api/v1/fs",
|
|
params={"uri": uri, "recursive": False},
|
|
)
|
|
assert result == {
|
|
"status": "deleted",
|
|
"uri": uri,
|
|
"estimated_deleted_count": 1,
|
|
}
|
|
|
|
|
|
def test_handle_tool_call_forget_deletes_exact_memory_file_under_memories_root():
|
|
uri = "viking://user/default/memories/profile.md"
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.delete.return_value = {
|
|
"status": "ok",
|
|
"result": {"uri": uri, "estimated_deleted_count": 1},
|
|
}
|
|
|
|
result = json.loads(provider.handle_tool_call("viking_forget", {"uri": uri}))
|
|
|
|
provider._client.delete.assert_called_once_with(
|
|
"/api/v1/fs",
|
|
params={"uri": uri, "recursive": False},
|
|
)
|
|
assert result == {
|
|
"status": "deleted",
|
|
"uri": uri,
|
|
"estimated_deleted_count": 1,
|
|
}
|
|
|
|
|
|
def test_handle_tool_call_forget_allows_non_generated_dot_md_memory_file():
|
|
uri = "viking://user/default/memories/preferences/.full.md"
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._client.delete.return_value = {
|
|
"status": "ok",
|
|
"result": {"uri": uri, "estimated_deleted_count": 1},
|
|
}
|
|
|
|
result = json.loads(provider.handle_tool_call("viking_forget", {"uri": uri}))
|
|
|
|
provider._client.delete.assert_called_once_with(
|
|
"/api/v1/fs",
|
|
params={"uri": uri, "recursive": False},
|
|
)
|
|
assert result == {
|
|
"status": "deleted",
|
|
"uri": uri,
|
|
"estimated_deleted_count": 1,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("uri", [
|
|
"",
|
|
"https://example.com/mem.md",
|
|
"viking:/user/memories/preferences/mem_abc123.md",
|
|
"viking://resources/project/doc.md",
|
|
"viking://resources/project/memories/mem_abc123.md",
|
|
"viking://memories/preferences/mem_abc123.md",
|
|
"viking://agent/hermes/memories/preferences/mem_abc123.md",
|
|
"viking://user/skills/example/SKILL.md",
|
|
"viking://user/sessions/session-1/messages.jsonl",
|
|
"viking://user/memories/preferences/",
|
|
"viking://user/memories/preferences/.overview.md",
|
|
"viking://user/memories/preferences/.abstract.md",
|
|
"viking://user/memories/preferences/mem_abc123.md?recursive=true",
|
|
])
|
|
def test_handle_tool_call_forget_rejects_non_memory_file_uris(uri):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
|
|
result = json.loads(provider.handle_tool_call("viking_forget", {"uri": uri}))
|
|
|
|
assert "error" in result
|
|
provider._client.delete.assert_not_called()
|
|
|
|
|
|
def test_viking_client_delete_uses_identity_headers(monkeypatch):
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="acct",
|
|
user="alice",
|
|
agent="hermes",
|
|
)
|
|
captured = {}
|
|
|
|
def capture_delete(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["kwargs"] = kwargs
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "result": {"uri": "viking://user/memories/x.md"}},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "delete", capture_delete)
|
|
|
|
assert client.delete("/api/v1/fs", params={"uri": "viking://user/memories/x.md"}) == {
|
|
"status": "ok",
|
|
"result": {"uri": "viking://user/memories/x.md"},
|
|
}
|
|
assert captured["url"] == "https://example.com/api/v1/fs"
|
|
assert captured["kwargs"]["params"] == {"uri": "viking://user/memories/x.md"}
|
|
assert captured["kwargs"]["headers"]["Authorization"] == "Bearer test-key"
|
|
assert captured["kwargs"]["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
|
|
|
|
|
|
def test_viking_client_post_allows_per_request_timeout(monkeypatch):
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="acct",
|
|
user="alice",
|
|
agent="hermes",
|
|
)
|
|
captured = {}
|
|
|
|
def capture_post(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["kwargs"] = kwargs
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "result": {}},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "post", capture_post)
|
|
|
|
assert client.post("/api/v1/search/find", {"query": "anything"}, timeout=1.25) == {
|
|
"status": "ok",
|
|
"result": {},
|
|
}
|
|
assert captured["url"] == "https://example.com/api/v1/search/find"
|
|
assert captured["kwargs"]["timeout"] == 1.25
|
|
|
|
|
|
def test_viking_client_upload_temp_file_uses_multipart_identity_headers(tmp_path, monkeypatch):
|
|
sample = tmp_path / "sample.md"
|
|
sample.write_text("# Local resource\n", encoding="utf-8")
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="test-account",
|
|
user="test-user",
|
|
agent="test-agent",
|
|
)
|
|
captured_kwargs = {}
|
|
|
|
def capture_httpx_post(url, **kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "result": {"temp_file_id": "upload_sample.md"}},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "post", capture_httpx_post)
|
|
|
|
assert client.upload_temp_file(sample) == "upload_sample.md"
|
|
|
|
assert "files" in captured_kwargs
|
|
assert "json" not in captured_kwargs
|
|
headers = captured_kwargs["headers"]
|
|
assert "X-OpenViking-Account" not in headers
|
|
assert "X-OpenViking-User" not in headers
|
|
assert headers["X-OpenViking-Actor-Peer"] == "test-agent"
|
|
assert "X-OpenViking-Agent" not in headers
|
|
assert headers["X-API-Key"] == "test-key"
|
|
assert "Content-Type" not in headers
|
|
|
|
|
|
def test_viking_client_raises_structured_server_error():
|
|
client = _VikingClient.__new__(_VikingClient)
|
|
response = SimpleNamespace(
|
|
status_code=403,
|
|
text='{"status":"error"}',
|
|
json=lambda: {
|
|
"status": "error",
|
|
"error": {
|
|
"code": "PERMISSION_DENIED",
|
|
"message": "direct host filesystem paths are not allowed",
|
|
},
|
|
},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="PERMISSION_DENIED"):
|
|
client._parse_response(response)
|
|
|
|
|
|
def test_viking_client_sanitizes_html_error_body():
|
|
client = _VikingClient.__new__(_VikingClient)
|
|
response = SimpleNamespace(
|
|
status_code=523,
|
|
text="""<!DOCTYPE html>
|
|
<html>
|
|
<head><title>tosaki.top | 523: Origin is unreachable</title></head>
|
|
<body>large Cloudflare error page</body>
|
|
</html>""",
|
|
json=lambda: (_ for _ in ()).throw(ValueError("not json")),
|
|
)
|
|
|
|
with pytest.raises(openviking_module._OpenVikingHTTPError) as exc_info:
|
|
client._parse_response(response)
|
|
|
|
message = str(exc_info.value)
|
|
assert "HTTP 523" in message
|
|
assert "Origin is unreachable" in message
|
|
assert "<!DOCTYPE" not in message
|
|
assert "<html" not in message
|
|
|
|
|
|
def test_viking_client_headers_include_bearer_when_api_key_set():
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="acct",
|
|
user="usr",
|
|
agent="hermes",
|
|
)
|
|
headers = client._headers()
|
|
assert headers["X-API-Key"] == "test-key"
|
|
assert headers["Authorization"] == "Bearer test-key"
|
|
assert headers["X-OpenViking-Actor-Peer"] == "hermes"
|
|
assert "X-OpenViking-Agent" not in headers
|
|
assert "X-OpenViking-Account" not in headers
|
|
assert "X-OpenViking-User" not in headers
|
|
|
|
|
|
def test_viking_client_headers_send_tenant_in_local_mode():
|
|
# Local/trusted mode needs explicit tenant identity headers.
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="",
|
|
account="default",
|
|
user="default",
|
|
agent="hermes",
|
|
)
|
|
headers = client._headers()
|
|
assert headers["X-OpenViking-Account"] == "default"
|
|
assert headers["X-OpenViking-User"] == "default"
|
|
assert headers["X-OpenViking-Actor-Peer"] == "hermes"
|
|
assert "X-OpenViking-Agent" not in headers
|
|
assert "Authorization" not in headers
|
|
|
|
|
|
def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(monkeypatch):
|
|
_clear_openviking_tenant_env(monkeypatch)
|
|
# Empty account/user strings fall back to "default" in local mode.
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="",
|
|
account="",
|
|
user="",
|
|
agent="hermes",
|
|
)
|
|
headers = client._headers()
|
|
assert headers["X-OpenViking-Account"] == "default"
|
|
assert headers["X-OpenViking-User"] == "default"
|
|
assert headers["X-OpenViking-Actor-Peer"] == "hermes"
|
|
assert "X-OpenViking-Agent" not in headers
|
|
assert "Authorization" not in headers
|
|
assert "X-API-Key" not in headers
|
|
|
|
|
|
def test_viking_client_headers_can_include_tenant_for_trusted_retry():
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="real-account",
|
|
user="real-user",
|
|
agent="hermes",
|
|
)
|
|
headers = client._headers(include_tenant=True)
|
|
assert headers["X-OpenViking-Account"] == "real-account"
|
|
assert headers["X-OpenViking-User"] == "real-user"
|
|
assert headers["Authorization"] == "Bearer test-key"
|
|
|
|
|
|
def test_viking_client_retries_with_tenant_headers_for_trusted_mode(monkeypatch):
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="acct",
|
|
user="usr",
|
|
agent="hermes",
|
|
)
|
|
captured_headers = []
|
|
|
|
def capture_get(url, **kwargs):
|
|
captured_headers.append(kwargs.get("headers") or {})
|
|
if len(captured_headers) == 1:
|
|
return SimpleNamespace(
|
|
status_code=400,
|
|
text="",
|
|
json=lambda: {
|
|
"status": "error",
|
|
"error": {
|
|
"code": "INVALID_ARGUMENT",
|
|
"message": "Trusted mode requests must include X-OpenViking-Account.",
|
|
},
|
|
},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "result": {"ok": True}},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", capture_get)
|
|
|
|
assert client.get("/api/v1/system/status") == {
|
|
"status": "ok",
|
|
"result": {"ok": True},
|
|
}
|
|
assert "X-OpenViking-Account" not in captured_headers[0]
|
|
assert "X-OpenViking-User" not in captured_headers[0]
|
|
assert captured_headers[1]["X-OpenViking-Account"] == "acct"
|
|
assert captured_headers[1]["X-OpenViking-User"] == "usr"
|
|
|
|
|
|
def test_viking_client_health_sends_auth_headers(monkeypatch):
|
|
_clear_openviking_tenant_env(monkeypatch)
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="",
|
|
user="",
|
|
agent="hermes",
|
|
)
|
|
captured = {}
|
|
|
|
def capture_get(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = kwargs.get("headers") or {}
|
|
return SimpleNamespace(status_code=200)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", capture_get)
|
|
assert client.health() is True
|
|
assert captured["url"] == "https://example.com/health"
|
|
assert captured["headers"]["Authorization"] == "Bearer test-key"
|
|
assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
|
|
assert "X-OpenViking-Agent" not in captured["headers"]
|
|
assert "X-OpenViking-Account" not in captured["headers"]
|
|
assert "X-OpenViking-User" not in captured["headers"]
|
|
|
|
|
|
def test_viking_client_validate_auth_uses_authenticated_system_status(monkeypatch):
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="test-key",
|
|
account="acct",
|
|
user="alice",
|
|
agent="hermes",
|
|
)
|
|
captured = {}
|
|
|
|
def capture_get(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = kwargs.get("headers") or {}
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "result": {"initialized": True}},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", capture_get)
|
|
|
|
assert client.validate_auth() == {
|
|
"status": "ok",
|
|
"result": {"initialized": True},
|
|
}
|
|
assert captured["url"] == "https://example.com/api/v1/system/status"
|
|
assert captured["headers"]["Authorization"] == "Bearer test-key"
|
|
assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
|
|
assert "X-OpenViking-Account" not in captured["headers"]
|
|
assert "X-OpenViking-User" not in captured["headers"]
|
|
|
|
|
|
def test_viking_client_validate_root_access_uses_admin_accounts(monkeypatch):
|
|
_clear_openviking_tenant_env(monkeypatch)
|
|
client = _VikingClient(
|
|
"https://example.com",
|
|
api_key="root-key",
|
|
account="",
|
|
user="",
|
|
agent="hermes",
|
|
)
|
|
captured = {}
|
|
|
|
def capture_get(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = kwargs.get("headers") or {}
|
|
return SimpleNamespace(
|
|
status_code=200,
|
|
text="",
|
|
json=lambda: {"status": "ok", "result": []},
|
|
raise_for_status=lambda: None,
|
|
)
|
|
|
|
monkeypatch.setattr(client._httpx, "get", capture_get)
|
|
|
|
assert client.validate_root_access() == {"status": "ok", "result": []}
|
|
assert captured["url"] == "https://example.com/api/v1/admin/accounts"
|
|
assert captured["headers"]["Authorization"] == "Bearer root-key"
|
|
assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
|
|
assert "X-OpenViking-Account" not in captured["headers"]
|
|
assert "X-OpenViking-User" not in captured["headers"]
|
|
|
|
|
|
def test_validate_openviking_reachability_uses_health_only(monkeypatch):
|
|
events = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://openviking.example"
|
|
assert api_key == ""
|
|
|
|
def health(self):
|
|
events.append("health")
|
|
return True
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message = openviking_module._validate_openviking_reachability(
|
|
"https://openviking.example"
|
|
)
|
|
|
|
assert ok is True
|
|
assert message == ""
|
|
assert events == ["health"]
|
|
|
|
|
|
def test_validate_openviking_auth_uses_status_without_health(monkeypatch):
|
|
events = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://openviking.example"
|
|
assert api_key == "test-key"
|
|
assert account == "acct"
|
|
assert user == "alice"
|
|
assert agent == "hermes"
|
|
|
|
def validate_auth(self):
|
|
events.append("status")
|
|
return {"status": "ok"}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message = openviking_module._validate_openviking_auth({
|
|
"endpoint": "https://openviking.example",
|
|
"api_key": "test-key",
|
|
"account": "acct",
|
|
"user": "alice",
|
|
"agent": "hermes",
|
|
})
|
|
|
|
assert ok is True
|
|
assert message == ""
|
|
assert events == ["status"]
|
|
|
|
|
|
def test_validate_openviking_root_access_uses_admin_endpoint(monkeypatch):
|
|
events = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://openviking.example"
|
|
assert api_key == "root-key"
|
|
assert account == ""
|
|
assert user == ""
|
|
assert agent == "hermes"
|
|
|
|
def validate_root_access(self):
|
|
events.append("admin")
|
|
return {"status": "ok"}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message = openviking_module._validate_openviking_root_access({
|
|
"endpoint": "https://openviking.example",
|
|
"api_key": "root-key",
|
|
})
|
|
|
|
assert ok is True
|
|
assert message == ""
|
|
assert events == ["admin"]
|
|
|
|
|
|
def test_validate_openviking_setup_values_blocks_remote_without_api_key(monkeypatch):
|
|
class FakeVikingClient:
|
|
def __init__(self, *args, **kwargs):
|
|
raise AssertionError("remote configs without API keys should fail before network validation")
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message, role = openviking_module._validate_openviking_setup_values(
|
|
{"endpoint": "https://openviking.example"},
|
|
require_api_key=True,
|
|
)
|
|
|
|
assert ok is False
|
|
assert message == "Remote OpenViking configs require an API key."
|
|
assert role is None
|
|
|
|
|
|
def test_validate_openviking_setup_values_local_dev_no_key_uses_health_only(monkeypatch):
|
|
events = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "http://localhost:1933"
|
|
assert api_key == ""
|
|
|
|
def health_payload(self):
|
|
events.append("health")
|
|
return {"healthy": True, "auth_mode": "dev"}
|
|
|
|
def validate_auth(self):
|
|
raise AssertionError("dev-mode no-key setup should not run authenticated status check")
|
|
|
|
def validate_root_access(self):
|
|
raise AssertionError("no-key setup should not run root probe")
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message, role = openviking_module._validate_openviking_setup_values(
|
|
{"endpoint": "localhost", "agent": "hermes"}
|
|
)
|
|
|
|
assert ok is True
|
|
assert message == ""
|
|
assert role is None
|
|
assert events == ["health"]
|
|
|
|
|
|
def test_validate_openviking_setup_values_user_key_runs_status_and_classifies_role(monkeypatch):
|
|
events = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://openviking.example"
|
|
assert api_key == "user-key"
|
|
assert account == ""
|
|
assert user == ""
|
|
|
|
def health_payload(self):
|
|
events.append("health")
|
|
return {"healthy": True}
|
|
|
|
def validate_auth(self):
|
|
events.append("status")
|
|
return {"status": "ok"}
|
|
|
|
def validate_root_access(self):
|
|
events.append("admin")
|
|
raise openviking_module._OpenVikingHTTPError("forbidden", 403)
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message, role = openviking_module._validate_openviking_setup_values(
|
|
{"endpoint": "https://openviking.example", "api_key": "user-key"},
|
|
require_api_key=True,
|
|
)
|
|
|
|
assert ok is True
|
|
assert message == ""
|
|
assert role == "user"
|
|
assert events == ["health", "status", "admin"]
|
|
|
|
|
|
def test_validate_openviking_setup_values_root_key_runs_admin_probe(monkeypatch):
|
|
events = []
|
|
|
|
class FakeVikingClient:
|
|
def __init__(self, endpoint, api_key="", account="", user="", agent=""):
|
|
assert endpoint == "https://openviking.example"
|
|
assert api_key == "root-key"
|
|
assert account == "acct"
|
|
assert user == "alice"
|
|
|
|
def health_payload(self):
|
|
events.append("health")
|
|
return {"healthy": True}
|
|
|
|
def validate_auth(self):
|
|
events.append("status")
|
|
return {"status": "ok"}
|
|
|
|
def validate_root_access(self):
|
|
events.append("admin")
|
|
return {"accounts": []}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", FakeVikingClient)
|
|
|
|
ok, message, role = openviking_module._validate_openviking_setup_values(
|
|
{
|
|
"endpoint": "https://openviking.example",
|
|
"api_key": "root-key",
|
|
"account": "acct",
|
|
"user": "alice",
|
|
},
|
|
require_api_key=True,
|
|
)
|
|
|
|
assert ok is True
|
|
assert message == ""
|
|
assert role == "root"
|
|
assert events == ["health", "status", "admin"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "field", "ok"),
|
|
[
|
|
("acct", "account", True),
|
|
("alice@example.com", "user", True),
|
|
("_system", "account", False),
|
|
("bad/user", "user", False),
|
|
("alice@@example.com", "user", False),
|
|
(" alice", "user", False),
|
|
],
|
|
)
|
|
def test_validate_openviking_identity_value_matches_cli_rules(value, field, ok):
|
|
valid, _message, normalized = openviking_module._validate_openviking_identity_value(
|
|
value,
|
|
field=field,
|
|
)
|
|
|
|
assert valid is ok
|
|
assert bool(normalized) is ok
|
|
# ---------------------------------------------------------------------------
|
|
# on_session_switch — flush + commit + rotate behavior (hermes-agent#28296)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_provider_with_session(session_id: str, turn_count: int):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._session_id = session_id
|
|
provider._turn_count = turn_count
|
|
return provider
|
|
|
|
|
|
def test_on_session_switch_commits_old_session_and_rotates_id():
|
|
provider = _make_provider_with_session("old-sid", turn_count=3)
|
|
|
|
provider.on_session_switch("new-sid", parent_session_id="old-sid")
|
|
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_on_session_switch_skips_commit_for_empty_old_session():
|
|
"""No turns accumulated → nothing to extract → no commit call."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=0)
|
|
|
|
provider.on_session_switch("new-sid")
|
|
|
|
provider._client.post.assert_not_called()
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_on_session_switch_commits_pending_tokens_without_turn_count():
|
|
provider = _make_provider_with_session("old-sid", turn_count=0)
|
|
provider._client.get.return_value = {"result": {"pending_tokens": 42}}
|
|
|
|
provider.on_session_switch("new-sid")
|
|
|
|
provider._client.get.assert_called_once_with("/api/v1/sessions/old-sid")
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_on_session_switch_rewound_same_session_skips_commit_and_rotation():
|
|
provider = _make_provider_with_session("same-sid", turn_count=3)
|
|
|
|
provider.on_session_switch("same-sid", rewound=True)
|
|
|
|
provider._client.get.assert_not_called()
|
|
provider._client.post.assert_not_called()
|
|
assert provider._session_id == "same-sid"
|
|
assert provider._turn_count == 3
|
|
|
|
|
|
def test_on_session_switch_waits_for_inflight_sync_thread():
|
|
"""In-flight sync_turn write must drain before the commit fires —
|
|
otherwise the commit can race the last message write."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
|
|
join_calls = []
|
|
|
|
class FakeThread:
|
|
def __init__(self):
|
|
self._alive = True
|
|
|
|
def is_alive(self):
|
|
return self._alive
|
|
|
|
def join(self, timeout=None):
|
|
join_calls.append(timeout)
|
|
# Simulate a worker that finishes within the join window.
|
|
self._alive = False
|
|
|
|
provider._inflight_writers["old-sid"] = {FakeThread()}
|
|
|
|
provider.on_session_switch("new-sid")
|
|
|
|
assert join_calls, "expected on_session_switch to join the in-flight sync thread"
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
|
|
|
|
def test_on_session_switch_noop_on_empty_new_id():
|
|
provider = _make_provider_with_session("old-sid", turn_count=5)
|
|
|
|
provider.on_session_switch("")
|
|
provider.on_session_switch(" ")
|
|
|
|
provider._client.post.assert_not_called()
|
|
assert provider._session_id == "old-sid"
|
|
assert provider._turn_count == 5
|
|
|
|
|
|
def test_on_session_switch_noop_when_client_missing():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = None
|
|
provider._session_id = "old-sid"
|
|
provider._turn_count = 4
|
|
|
|
# Must not raise even though no client is configured.
|
|
provider.on_session_switch("new-sid")
|
|
|
|
# State stays untouched — provider is effectively disabled.
|
|
assert provider._session_id == "old-sid"
|
|
assert provider._turn_count == 4
|
|
|
|
|
|
def test_sync_turn_captures_session_id_before_worker_runs():
|
|
"""Worker must use the session id snapshotted at sync_turn() call time, not
|
|
re-read self._session_id later — otherwise a delayed worker can write the
|
|
previous turn's messages into the rotated-in NEW session."""
|
|
import threading
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "old-sid"
|
|
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
captured_paths = []
|
|
captured_payloads = []
|
|
|
|
def fake_post(path, payload=None, **kwargs):
|
|
started.set()
|
|
release.wait(timeout=2.0)
|
|
captured_paths.append(path)
|
|
captured_payloads.append(payload)
|
|
return {}
|
|
|
|
# Patch _VikingClient inside the worker by stubbing post on a client
|
|
# the constructor will produce. Easiest path: monkeypatch the class.
|
|
real_client_cls = _VikingClient
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
return fake_post(path, payload, **kwargs)
|
|
|
|
import plugins.memory.openviking as _mod
|
|
_mod._VikingClient = StubClient
|
|
try:
|
|
provider.sync_turn("u", "a")
|
|
# Wait until the worker is parked inside the first post call.
|
|
assert started.wait(timeout=2.0), "worker never entered post()"
|
|
# Rotate the provider's session id while the worker is mid-flight.
|
|
provider._session_id = "new-sid"
|
|
release.set()
|
|
for t in list(provider._inflight_writers.get("old-sid", set())):
|
|
t.join(timeout=2.0)
|
|
finally:
|
|
_mod._VikingClient = real_client_cls
|
|
|
|
# The whole turn must target the OLD session id as a single ordered batch.
|
|
assert captured_paths == ["/api/v1/sessions/old-sid/messages/batch"]
|
|
assert captured_payloads == [{
|
|
"messages": [
|
|
{"role": "user", "parts": [{"type": "text", "text": "u"}]},
|
|
{"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
|
|
]
|
|
}]
|
|
|
|
|
|
def test_sync_turn_retries_batch_write_with_fresh_client():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "sid-1"
|
|
|
|
clients = []
|
|
captured = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
self.index = len(clients)
|
|
clients.append(self)
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
if self.index == 0:
|
|
raise RuntimeError("transient")
|
|
captured.append((path, payload))
|
|
return {}
|
|
|
|
import plugins.memory.openviking as _mod
|
|
real_client_cls = _mod._VikingClient
|
|
_mod._VikingClient = StubClient
|
|
try:
|
|
provider.sync_turn("u", "a")
|
|
assert provider._drain_writers("sid-1", timeout=2.0)
|
|
finally:
|
|
_mod._VikingClient = real_client_cls
|
|
|
|
assert len(clients) == 2
|
|
assert captured == [(
|
|
"/api/v1/sessions/sid-1/messages/batch",
|
|
{
|
|
"messages": [
|
|
{"role": "user", "parts": [{"type": "text", "text": "u"}]},
|
|
{"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
|
|
]
|
|
},
|
|
)]
|
|
|
|
|
|
def _long_structured_turn(assistant_count=204):
|
|
return [
|
|
{"role": "user", "content": "u"},
|
|
*[
|
|
{"role": "assistant", "content": f"assistant-{index}"}
|
|
for index in range(assistant_count)
|
|
],
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("assistant_count", "expected_chunk_sizes"),
|
|
[
|
|
(99, [100]),
|
|
(100, [100, 1]),
|
|
(204, [100, 100, 5]),
|
|
],
|
|
)
|
|
def test_sync_turn_chunks_structured_messages_to_openviking_limit(
|
|
monkeypatch,
|
|
assistant_count,
|
|
expected_chunk_sizes,
|
|
):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "sid-chunked"
|
|
|
|
captured = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured.append((path, payload))
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
messages = _long_structured_turn(assistant_count)
|
|
|
|
provider.sync_turn("u", f"assistant-{assistant_count - 1}", messages=messages)
|
|
assert provider._drain_writers("sid-chunked", timeout=2.0)
|
|
|
|
assert [
|
|
len(payload["messages"])
|
|
for _path, payload in captured
|
|
] == expected_chunk_sizes
|
|
assert all(path == "/api/v1/sessions/sid-chunked/messages/batch" for path, _ in captured)
|
|
assert [
|
|
message
|
|
for _path, payload in captured
|
|
for message in payload["messages"]
|
|
] == provider._messages_to_openviking_batch(messages, assistant_peer_id="hermes")
|
|
|
|
|
|
def test_sync_turn_retries_only_unsent_chunks_with_fresh_client(monkeypatch):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "sid-resume"
|
|
|
|
clients = []
|
|
attempts = []
|
|
accepted = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *args, **kwargs):
|
|
self.index = len(clients)
|
|
clients.append(self)
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
attempts.append((self.index, path, payload))
|
|
if self.index == 0 and len([item for item in attempts if item[0] == 0]) == 2:
|
|
raise RuntimeError("transient second chunk failure")
|
|
accepted.extend(payload["messages"])
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
messages = _long_structured_turn()
|
|
|
|
provider.sync_turn("u", "assistant-203", messages=messages)
|
|
assert provider._drain_writers("sid-resume", timeout=2.0)
|
|
|
|
assert len(clients) == 2
|
|
assert [
|
|
(client_index, len(payload["messages"]))
|
|
for client_index, _path, payload in attempts
|
|
] == [(0, 100), (0, 100), (1, 100), (1, 5)]
|
|
assert accepted == provider._messages_to_openviking_batch(
|
|
messages,
|
|
assistant_peer_id="hermes",
|
|
)
|
|
|
|
|
|
def test_sync_turn_falls_back_to_individual_writes_for_unsent_chunks(monkeypatch):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "sid-individual-fallback"
|
|
|
|
clients = []
|
|
accepted = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *args, **kwargs):
|
|
self.index = len(clients)
|
|
clients.append(self)
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
if path.endswith("/messages/batch"):
|
|
if self.index == 0 and not accepted:
|
|
accepted.extend(payload["messages"])
|
|
return {}
|
|
raise RuntimeError("persistent batch failure")
|
|
assert path == "/api/v1/sessions/sid-individual-fallback/messages"
|
|
accepted.append(payload)
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
messages = _long_structured_turn()
|
|
|
|
provider.sync_turn("u", "assistant-203", messages=messages)
|
|
assert provider._drain_writers("sid-individual-fallback", timeout=2.0)
|
|
|
|
assert len(clients) == 2
|
|
assert accepted == provider._messages_to_openviking_batch(
|
|
messages,
|
|
assistant_peer_id="hermes",
|
|
)
|
|
|
|
|
|
def test_sync_turn_structured_messages_include_assistant_peer_id():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "sid-structured"
|
|
|
|
captured = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured.append((path, payload))
|
|
return {}
|
|
|
|
import plugins.memory.openviking as _mod
|
|
|
|
real_client_cls = _mod._VikingClient
|
|
_mod._VikingClient = StubClient
|
|
messages = [
|
|
{"role": "user", "content": [{"type": "input_text", "text": "u"}]},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Looking.",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call-1",
|
|
"type": "function",
|
|
"function": {"name": "shell_command", "arguments": json.dumps({"cmd": "pwd"})},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call-1", "name": "shell_command", "content": "ok"},
|
|
{"role": "assistant", "content": [{"type": "output_text", "text": "a"}]},
|
|
]
|
|
try:
|
|
provider.sync_turn("u", "a", messages=messages)
|
|
assert provider._drain_writers("sid-structured", timeout=2.0)
|
|
finally:
|
|
_mod._VikingClient = real_client_cls
|
|
|
|
assert captured == [(
|
|
"/api/v1/sessions/sid-structured/messages/batch",
|
|
{
|
|
"messages": [
|
|
{"role": "user", "parts": [{"type": "text", "text": "u"}]},
|
|
{"role": "assistant", "parts": [{"type": "text", "text": "Looking."}], "peer_id": "hermes"},
|
|
{
|
|
"role": "assistant",
|
|
"parts": [
|
|
{
|
|
"type": "tool",
|
|
"tool_id": "call-1",
|
|
"tool_name": "shell_command",
|
|
"tool_input": {"cmd": "pwd"},
|
|
"tool_output": "ok",
|
|
"tool_status": "completed",
|
|
}
|
|
],
|
|
"peer_id": "hermes",
|
|
},
|
|
{"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
|
|
]
|
|
},
|
|
)]
|
|
|
|
|
|
def test_sync_turn_noop_when_session_id_blank():
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._session_id = ""
|
|
|
|
provider.sync_turn("u", "a")
|
|
|
|
# No turn counted, no worker spawned.
|
|
assert provider._turn_count == 0
|
|
assert provider._inflight_writers == {}
|
|
|
|
|
|
def test_on_session_end_marks_session_clean_after_successful_commit():
|
|
"""After a successful commit on_session_end must reset _turn_count so a
|
|
subsequent on_session_switch (fired by /new and compression right after
|
|
commit_memory_session) skips its commit instead of double-committing."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=3)
|
|
|
|
provider.on_session_end([])
|
|
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_on_session_end_keeps_dirty_when_commit_fails():
|
|
"""If the commit fails, leave _turn_count > 0 so on_session_switch retries
|
|
rather than silently dropping extraction for the old session."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=3)
|
|
provider._client.post.side_effect = RuntimeError("commit boom")
|
|
|
|
provider.on_session_end([])
|
|
|
|
assert provider._turn_count == 3
|
|
|
|
|
|
def test_on_session_end_commits_pending_tokens_without_turn_count():
|
|
provider = _make_provider_with_session("old-sid", turn_count=0)
|
|
provider._client.get.return_value = {"result": {"pending_tokens": 42}}
|
|
|
|
provider.on_session_end([])
|
|
|
|
provider._client.get.assert_called_once_with("/api/v1/sessions/old-sid")
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
|
|
|
|
def test_end_then_switch_does_not_double_commit():
|
|
"""Mirrors the /new and compression call order: commit_memory_session
|
|
(→ on_session_end) immediately followed by on_session_switch. The switch
|
|
must NOT issue a second commit on the same session id."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
|
|
provider.on_session_end([])
|
|
provider.on_session_switch("new-sid", parent_session_id="old-sid")
|
|
|
|
# Exactly one commit call, on the OLD session, fired by on_session_end.
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_end_then_switch_with_pending_tokens_does_not_double_commit():
|
|
provider = _make_provider_with_session("old-sid", turn_count=0)
|
|
provider._client.get.return_value = {"result": {"pending_tokens": 42}}
|
|
|
|
provider.on_session_end([])
|
|
provider.on_session_switch("new-sid", parent_session_id="old-sid")
|
|
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_session_needs_commit_guard_wins_over_stale_turn_count():
|
|
"""Regression for hermes-agent#28296 review (M3): once a session is marked
|
|
committed, _session_needs_commit must return False even if turn_count is
|
|
still positive. A racing sync_turn can re-increment _turn_count after the
|
|
commit+reset; without the guard ordering, a follow-up finalizer would
|
|
double-commit the same session. The committed-guard must be checked BEFORE
|
|
the turn_count>0 shortcut."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=5)
|
|
provider._mark_session_committed("old-sid")
|
|
|
|
# turn_count is a (stale) 5 but the session is already committed.
|
|
assert provider._session_needs_commit("old-sid", 5) is False
|
|
# An uncommitted session with turns still needs a commit.
|
|
assert provider._session_needs_commit("fresh-sid", 5) is True
|
|
|
|
|
|
def test_on_session_switch_swallows_commit_failure():
|
|
"""Commit-on-switch must not propagate exceptions: a failing commit on the
|
|
old session must still allow the rotate to the new session to complete,
|
|
otherwise subsequent sync_turn writes would land in the wrong session."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
provider._client.post.side_effect = RuntimeError("commit boom")
|
|
|
|
provider.on_session_switch("new-sid")
|
|
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hung-writer protection: the sync worker can outlive the bounded join
|
|
# because each OpenViking POST has _TIMEOUT=30s and there are two per turn.
|
|
# Committing while late writes are still in flight would orphan them past
|
|
# the commit boundary — they would never be extracted.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _HungThread:
|
|
"""Thread stand-in that stays alive across joins."""
|
|
|
|
def is_alive(self):
|
|
return True
|
|
|
|
def join(self, timeout=None):
|
|
# Pretend the join timed out — worker still running.
|
|
return None
|
|
|
|
|
|
def test_on_session_end_skips_commit_when_sync_worker_outlives_join():
|
|
"""If the sync worker is still alive after the 10s join, the commit must
|
|
be skipped — late writes from the worker would otherwise land in an
|
|
already-committed session and never be extracted. Leave _turn_count
|
|
intact so the session stays marked dirty."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=3)
|
|
provider._inflight_writers["old-sid"] = {_HungThread()}
|
|
|
|
provider.on_session_end([])
|
|
|
|
provider._client.post.assert_not_called()
|
|
assert provider._turn_count == 3
|
|
|
|
|
|
def test_on_session_switch_skips_commit_when_sync_worker_outlives_join():
|
|
"""Same hazard on the switch path. Rotation must still proceed (the new
|
|
session needs to start) but the old-session commit is skipped to avoid
|
|
orphaning the worker's late writes past commit."""
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
provider._inflight_writers["old-sid"] = {_HungThread()}
|
|
|
|
provider.on_session_switch("new-sid")
|
|
|
|
provider._client.post.assert_not_called()
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orphaned-writer hazard: commit must wait for ALL writers for the session,
|
|
# not just the latest tracked one. sync_turn's bounded rate-limit can drop a
|
|
# still-alive previous worker — that dropped writer keeps POSTing under the
|
|
# old sid and would otherwise land its writes past the commit boundary.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_on_session_end_waits_for_all_writers_not_just_latest():
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
provider._inflight_writers["old-sid"] = {_HungThread()}
|
|
|
|
provider.on_session_end([])
|
|
|
|
provider._client.post.assert_not_called()
|
|
assert provider._turn_count == 2
|
|
|
|
|
|
def test_on_session_switch_waits_for_all_writers_not_just_latest():
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
provider._inflight_writers["old-sid"] = {_HungThread()}
|
|
|
|
provider.on_session_switch("new-sid")
|
|
|
|
provider._client.post.assert_not_called()
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
|
|
|
|
def test_on_session_switch_does_not_block_caller_on_slow_drain():
|
|
"""Regression for hermes-agent#28296 review (H1): on_session_switch must
|
|
NOT run the old-session drain/commit on the caller's thread. /new, /branch,
|
|
/resume, /undo call this synchronously on the command thread, so a slow
|
|
writer drain (up to _SESSION_DRAIN_TIMEOUT/_DEFERRED_COMMIT_TIMEOUT) or a
|
|
wedged commit POST must not stall the user-facing command. The rotation is
|
|
cheap and synchronous; the commit is offloaded. Mirrors the #41945
|
|
'do not block the turn thread' contract."""
|
|
import threading
|
|
import time
|
|
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
|
|
drain_entered = threading.Event()
|
|
release_drain = threading.Event()
|
|
|
|
def slow_drain(sid, timeout):
|
|
drain_entered.set()
|
|
# Simulate a writer that takes a long time to drain.
|
|
release_drain.wait(timeout=10.0)
|
|
return True
|
|
|
|
provider._drain_writers = slow_drain
|
|
|
|
start = time.monotonic()
|
|
provider.on_session_switch("new-sid")
|
|
elapsed = time.monotonic() - start
|
|
|
|
# The caller returned promptly with state already rotated, even though the
|
|
# drain is still parked on the finalizer thread.
|
|
assert elapsed < 1.0, f"on_session_switch blocked the caller for {elapsed:.2f}s"
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
assert drain_entered.wait(timeout=2.0), "finalizer never started draining"
|
|
# No commit yet — drain is still blocked off-thread.
|
|
provider._client.post.assert_not_called()
|
|
# Let the finalizer finish so it doesn't leak past the test.
|
|
release_drain.set()
|
|
assert provider._drain_finalizers(timeout=5.0)
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
|
|
|
|
def test_on_session_switch_defers_old_commit_to_finalizer_thread():
|
|
"""The switch path rotates session state synchronously (cheap, in-memory)
|
|
but offloads the old-session drain + commit onto a daemon finalizer so the
|
|
caller's command thread (/new, /branch, /resume) never blocks on the up-to
|
|
-_DEFERRED_COMMIT_TIMEOUT drain or the commit POST. See hermes-agent#28296
|
|
review (the #41945 'do not block the turn thread' contract)."""
|
|
import threading
|
|
|
|
provider = _make_provider_with_session("old-sid", turn_count=2)
|
|
committed = threading.Event()
|
|
drain_timeouts = []
|
|
|
|
def fake_post(path, payload=None):
|
|
committed.set()
|
|
return {}
|
|
|
|
def fake_drain(sid, timeout):
|
|
drain_timeouts.append(timeout)
|
|
return True
|
|
|
|
provider._client.post.side_effect = fake_post
|
|
provider._drain_writers = fake_drain
|
|
|
|
provider.on_session_switch("new-sid")
|
|
|
|
# Rotation is synchronous and immediate — the new session is live at once.
|
|
assert provider._session_id == "new-sid"
|
|
assert provider._turn_count == 0
|
|
# The old-session commit lands on the finalizer thread, not inline.
|
|
assert committed.wait(timeout=5.0), "old session was not finalized off-thread"
|
|
provider._client.post.assert_called_once_with(
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)
|
|
# The finalizer drains with the deferred (longer) budget, not inline 10s.
|
|
assert drain_timeouts == [_DEFERRED_COMMIT_TIMEOUT]
|
|
|
|
|
|
def test_sync_turn_tracks_writer_under_session_id():
|
|
"""Every sync_turn writer must register under its captured sid so the
|
|
drain at end/switch sees it even if a later sync_turn replaces the
|
|
latest-tracked reference."""
|
|
import threading
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "sid-1"
|
|
|
|
release = threading.Event()
|
|
started = threading.Event()
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
started.set()
|
|
release.wait(timeout=2.0)
|
|
return {}
|
|
|
|
import plugins.memory.openviking as _mod
|
|
real_client_cls = _mod._VikingClient
|
|
_mod._VikingClient = StubClient
|
|
try:
|
|
provider.sync_turn("u", "a")
|
|
assert started.wait(timeout=2.0), "worker never entered post()"
|
|
assert len(provider._inflight_writers.get("sid-1", set())) == 1
|
|
release.set()
|
|
for t in list(provider._inflight_writers.get("sid-1", set())):
|
|
t.join(timeout=2.0)
|
|
finally:
|
|
_mod._VikingClient = real_client_cls
|
|
|
|
# Worker should have removed itself from the inflight set on exit.
|
|
assert provider._inflight_writers.get("sid-1", set()) == set()
|
|
|
|
|
|
def test_initialize_recovers_pending_session_from_previous_process(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
|
|
posts = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
posts.append((path, payload))
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
previous = OpenVikingMemoryProvider()
|
|
previous.initialize("old-sid", hermes_home=str(tmp_path))
|
|
previous._spawn_writer = lambda sid, target, name: None
|
|
previous.sync_turn("u", "a")
|
|
previous.shutdown()
|
|
|
|
fresh = OpenVikingMemoryProvider()
|
|
fresh.initialize("new-sid", hermes_home=str(tmp_path))
|
|
assert fresh._drain_finalizers(timeout=2.0)
|
|
|
|
commit = ("/api/v1/sessions/old-sid/commit", {"keep_recent_count": 0})
|
|
assert posts.count(commit) == 1
|
|
|
|
later = OpenVikingMemoryProvider()
|
|
later.initialize("third-sid", hermes_home=str(tmp_path))
|
|
assert later._drain_finalizers(timeout=2.0)
|
|
|
|
assert posts.count(commit) == 1
|
|
|
|
|
|
def test_initialize_skips_pending_session_owned_by_live_same_profile_provider(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
|
|
posts = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
posts.append((path, payload))
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
live_owner = OpenVikingMemoryProvider()
|
|
live_owner.initialize("owned-sid", hermes_home=str(tmp_path))
|
|
live_owner._spawn_writer = lambda sid, target, name: None
|
|
live_owner.sync_turn("u", "a")
|
|
marker = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR / "owned-sid.json"
|
|
assert json.loads(marker.read_text(encoding="utf-8"))["owner_run_id"] == live_owner._run_id
|
|
|
|
other_provider = OpenVikingMemoryProvider()
|
|
other_provider.initialize("other-sid", hermes_home=str(tmp_path))
|
|
assert other_provider._drain_finalizers(timeout=2.0)
|
|
|
|
assert (
|
|
"/api/v1/sessions/owned-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
) not in posts
|
|
|
|
live_owner.shutdown()
|
|
other_provider.shutdown()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX advisory locks")
|
|
@pytest.mark.parametrize("owner_run_id", ["dead-owner", ""])
|
|
def test_concurrent_providers_claim_unlocked_pending_owner_once(
|
|
tmp_path,
|
|
monkeypatch,
|
|
owner_run_id,
|
|
):
|
|
"""Only one provider may recover a missing or legacy owner lock."""
|
|
import threading
|
|
|
|
pytest.importorskip("fcntl")
|
|
_clear_openviking_env(monkeypatch)
|
|
|
|
pending_dir = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR
|
|
pending_dir.mkdir(parents=True)
|
|
marker = pending_dir / "old-sid.json"
|
|
marker.write_text(
|
|
json.dumps({"session_id": "old-sid", "owner_run_id": owner_run_id}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
posts = []
|
|
posts_lock = threading.Lock()
|
|
commit_started = threading.Event()
|
|
release_commit = threading.Event()
|
|
|
|
class StubClient:
|
|
def post(self, path, payload=None, **kwargs):
|
|
with posts_lock:
|
|
posts.append((path, payload))
|
|
commit_started.set()
|
|
release_commit.wait(timeout=5.0)
|
|
return {}
|
|
|
|
providers = [OpenVikingMemoryProvider(), OpenVikingMemoryProvider()]
|
|
scan_barrier = threading.Barrier(len(providers))
|
|
for provider in providers:
|
|
provider._client = StubClient()
|
|
provider._hermes_home = str(tmp_path)
|
|
pending_sessions = provider._pending_sessions
|
|
|
|
def _scan_together(scan=pending_sessions):
|
|
sessions = scan()
|
|
scan_barrier.wait(timeout=2.0)
|
|
return sessions
|
|
|
|
provider._pending_sessions = _scan_together
|
|
|
|
recovery_threads = [
|
|
threading.Thread(target=provider._recover_pending_sessions)
|
|
for provider in providers
|
|
]
|
|
for thread in recovery_threads:
|
|
thread.start()
|
|
for thread in recovery_threads:
|
|
thread.join(timeout=2.0)
|
|
assert not thread.is_alive()
|
|
|
|
assert commit_started.wait(timeout=2.0), "recovery commit did not start"
|
|
release_commit.set()
|
|
assert all(provider._drain_finalizers(timeout=2.0) for provider in providers)
|
|
|
|
assert posts.count((
|
|
"/api/v1/sessions/old-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)) == 1
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX advisory locks")
|
|
def test_initialize_recovers_free_owner_lock_once_and_cleans_marker(tmp_path, monkeypatch):
|
|
pytest.importorskip("fcntl")
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
|
|
pending_dir = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR
|
|
pending_dir.mkdir(parents=True)
|
|
marker = pending_dir / "old-sid.json"
|
|
marker.write_text(
|
|
json.dumps({"session_id": "old-sid", "owner_run_id": "dead-owner"}),
|
|
encoding="utf-8",
|
|
)
|
|
owner_lock = tmp_path / "openviking" / "runs" / "dead-owner.lock"
|
|
owner_lock.parent.mkdir(parents=True)
|
|
owner_lock.write_text("", encoding="utf-8")
|
|
|
|
posts = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
posts.append((path, payload))
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
fresh = OpenVikingMemoryProvider()
|
|
fresh.initialize("new-sid", hermes_home=str(tmp_path))
|
|
assert fresh._drain_finalizers(timeout=2.0)
|
|
|
|
commit = ("/api/v1/sessions/old-sid/commit", {"keep_recent_count": 0})
|
|
assert posts.count(commit) == 1
|
|
assert not marker.exists()
|
|
assert not owner_lock.exists()
|
|
|
|
later = OpenVikingMemoryProvider()
|
|
later.initialize("third-sid", hermes_home=str(tmp_path))
|
|
assert later._drain_finalizers(timeout=2.0)
|
|
|
|
assert posts.count(commit) == 1
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX advisory locks")
|
|
def test_initialize_recovers_multiple_pending_sessions_for_one_dead_owner(tmp_path, monkeypatch):
|
|
import threading
|
|
|
|
pytest.importorskip("fcntl")
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
|
|
pending_dir = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR
|
|
pending_dir.mkdir(parents=True)
|
|
owner_run_id = "dead-owner"
|
|
for sid in ("old-sid-a", "old-sid-b"):
|
|
(pending_dir / f"{sid}.json").write_text(
|
|
json.dumps({"session_id": sid, "owner_run_id": owner_run_id}),
|
|
encoding="utf-8",
|
|
)
|
|
owner_lock = tmp_path / "openviking" / "runs" / f"{owner_run_id}.lock"
|
|
owner_lock.parent.mkdir(parents=True)
|
|
owner_lock.write_text("", encoding="utf-8")
|
|
|
|
posts = []
|
|
first_commit_entered = threading.Event()
|
|
release_commit = threading.Event()
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
posts.append((path, payload))
|
|
if path == "/api/v1/sessions/old-sid-a/commit":
|
|
first_commit_entered.set()
|
|
release_commit.wait(timeout=5.0)
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
fresh = OpenVikingMemoryProvider()
|
|
fresh.initialize("new-sid", hermes_home=str(tmp_path))
|
|
|
|
assert first_commit_entered.wait(timeout=2.0), "first recovery commit did not start"
|
|
release_commit.set()
|
|
assert fresh._drain_finalizers(timeout=2.0)
|
|
|
|
assert posts.count((
|
|
"/api/v1/sessions/old-sid-a/commit",
|
|
{"keep_recent_count": 0},
|
|
)) == 1
|
|
assert posts.count((
|
|
"/api/v1/sessions/old-sid-b/commit",
|
|
{"keep_recent_count": 0},
|
|
)) == 1
|
|
assert not (pending_dir / "old-sid-a.json").exists()
|
|
assert not (pending_dir / "old-sid-b.json").exists()
|
|
assert not owner_lock.exists()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX advisory locks")
|
|
def test_initialize_skips_multiple_pending_sessions_for_one_live_owner(tmp_path, monkeypatch):
|
|
import threading
|
|
|
|
pytest.importorskip("fcntl")
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
|
|
commit_called = threading.Event()
|
|
release_commit = threading.Event()
|
|
posts = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
posts.append((path, payload))
|
|
if path.endswith("/commit"):
|
|
commit_called.set()
|
|
release_commit.wait(timeout=5.0)
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
live_owner = OpenVikingMemoryProvider()
|
|
live_owner.initialize("owned-sid", hermes_home=str(tmp_path))
|
|
|
|
pending_dir = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR
|
|
pending_dir.mkdir(parents=True)
|
|
for sid in ("owned-sid-a", "owned-sid-b"):
|
|
(pending_dir / f"{sid}.json").write_text(
|
|
json.dumps({"session_id": sid, "owner_run_id": live_owner._run_id}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
other_provider = OpenVikingMemoryProvider()
|
|
other_provider.initialize("other-sid", hermes_home=str(tmp_path))
|
|
assert other_provider._drain_finalizers(timeout=2.0)
|
|
|
|
release_commit.set()
|
|
assert not commit_called.is_set()
|
|
assert (
|
|
"/api/v1/sessions/owned-sid-a/commit",
|
|
{"keep_recent_count": 0},
|
|
) not in posts
|
|
assert (
|
|
"/api/v1/sessions/owned-sid-b/commit",
|
|
{"keep_recent_count": 0},
|
|
) not in posts
|
|
|
|
live_owner.shutdown()
|
|
other_provider.shutdown()
|
|
|
|
|
|
@pytest.mark.parametrize("advisory_locks", [True, False])
|
|
def test_initialize_recovers_legacy_pending_session_marker(
|
|
tmp_path,
|
|
monkeypatch,
|
|
advisory_locks,
|
|
):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
if not advisory_locks:
|
|
monkeypatch.setattr(openviking_module, "fcntl", None)
|
|
|
|
pending_dir = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR
|
|
pending_dir.mkdir(parents=True)
|
|
marker = pending_dir / "legacy-sid.json"
|
|
marker.write_text(json.dumps({"session_id": "legacy-sid"}), encoding="utf-8")
|
|
|
|
posts = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
posts.append((path, payload))
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
fresh = OpenVikingMemoryProvider()
|
|
fresh.initialize("new-sid", hermes_home=str(tmp_path))
|
|
assert fresh._drain_finalizers(timeout=2.0)
|
|
|
|
assert posts.count((
|
|
"/api/v1/sessions/legacy-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
)) == 1
|
|
assert not marker.exists()
|
|
|
|
|
|
def test_initialize_skips_owned_pending_marker_when_fcntl_unavailable(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
monkeypatch.setattr(openviking_module, "fcntl", None)
|
|
|
|
pending_dir = tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR
|
|
pending_dir.mkdir(parents=True)
|
|
marker = pending_dir / "owned-sid.json"
|
|
marker.write_text(
|
|
json.dumps({"session_id": "owned-sid", "owner_run_id": "owner-run"}),
|
|
encoding="utf-8",
|
|
)
|
|
owner_lock = tmp_path / "openviking" / "runs" / "owner-run.lock"
|
|
owner_lock.parent.mkdir(parents=True)
|
|
owner_lock.write_text("", encoding="utf-8")
|
|
posts = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
posts.append((path, payload))
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
fresh = OpenVikingMemoryProvider()
|
|
fresh.initialize("new-sid", hermes_home=str(tmp_path))
|
|
assert fresh._drain_finalizers(timeout=2.0)
|
|
|
|
assert (
|
|
"/api/v1/sessions/owned-sid/commit",
|
|
{"keep_recent_count": 0},
|
|
) not in posts
|
|
assert marker.exists()
|
|
|
|
|
|
def test_sync_turn_does_not_mark_owned_session_without_advisory_lock(tmp_path, monkeypatch):
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setattr(openviking_module, "fcntl", None)
|
|
|
|
class StubClient:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = StubClient()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "sid-no-lock"
|
|
provider._hermes_home = str(tmp_path)
|
|
provider._acquire_run_lock()
|
|
|
|
provider.sync_turn("u", "a")
|
|
assert provider._drain_writers("sid-no-lock", timeout=2.0)
|
|
|
|
assert provider._run_lock_path is None
|
|
assert not (tmp_path / openviking_module._PENDING_SESSIONS_RELATIVE_DIR).exists()
|
|
|
|
|
|
def test_initialize_recovers_pending_session_without_blocking_startup(tmp_path, monkeypatch):
|
|
import threading
|
|
|
|
_clear_openviking_env(monkeypatch)
|
|
monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://test")
|
|
|
|
post_entered = threading.Event()
|
|
release_post = threading.Event()
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def health_payload(self):
|
|
return {"healthy": True}
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
if path == "/api/v1/sessions/old-sid/commit":
|
|
post_entered.set()
|
|
release_post.wait(timeout=5.0)
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
previous = OpenVikingMemoryProvider()
|
|
previous.initialize("old-sid", hermes_home=str(tmp_path))
|
|
previous._spawn_writer = lambda sid, target, name: None
|
|
previous.sync_turn("u", "a")
|
|
previous.shutdown()
|
|
|
|
start = time.monotonic()
|
|
fresh = OpenVikingMemoryProvider()
|
|
fresh.initialize("new-sid", hermes_home=str(tmp_path))
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert elapsed < 3.0, f"startup recovery blocked initialize() for {elapsed:.2f}s"
|
|
assert post_entered.wait(timeout=2.0), "recovery commit did not start"
|
|
release_post.set()
|
|
assert fresh._drain_finalizers(timeout=2.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_memory_write: explicit memory writes use content/write and stay outside
|
|
# the session transcript/commit boundary.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_on_memory_write_uses_content_write_independent_of_session_rotation():
|
|
import threading
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
provider._session_id = "old-sid"
|
|
|
|
in_ctor = threading.Event()
|
|
release = threading.Event()
|
|
done = threading.Event()
|
|
captured_paths = []
|
|
captured_payloads = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
in_ctor.set()
|
|
release.wait(timeout=2.0)
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured_paths.append(path)
|
|
captured_payloads.append(payload)
|
|
done.set()
|
|
return {}
|
|
|
|
import plugins.memory.openviking as _mod
|
|
real_client_cls = _mod._VikingClient
|
|
_mod._VikingClient = StubClient
|
|
try:
|
|
provider.on_memory_write("add", "user", "remember this")
|
|
assert in_ctor.wait(timeout=2.0), "worker never entered ctor"
|
|
# Rotate provider's session id while the worker is parked. Memory writes
|
|
# must not become session messages in either the old or new session.
|
|
provider._session_id = "new-sid"
|
|
release.set()
|
|
assert done.wait(timeout=2.0), "worker never reached post()"
|
|
finally:
|
|
_mod._VikingClient = real_client_cls
|
|
|
|
assert captured_paths == ["/api/v1/content/write"]
|
|
assert captured_payloads[0]["content"] == "remember this"
|
|
assert captured_payloads[0]["mode"] == "create"
|
|
assert captured_payloads[0]["uri"].startswith(
|
|
"viking://user/peers/hermes/memories/preferences/mem_"
|
|
)
|
|
|
|
|
|
def test_shutdown_waits_for_memory_write_worker(monkeypatch):
|
|
import threading
|
|
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
|
|
worker_started = threading.Event()
|
|
release_worker = threading.Event()
|
|
worker_finished = threading.Event()
|
|
shutdown_returned = threading.Event()
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
assert path == "/api/v1/content/write"
|
|
worker_started.set()
|
|
release_worker.wait(timeout=2.0)
|
|
worker_finished.set()
|
|
return {}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider.on_memory_write("add", "user", "remember this")
|
|
assert worker_started.wait(timeout=2.0), "worker never entered post()"
|
|
|
|
shutdown_thread = threading.Thread(
|
|
target=lambda: (provider.shutdown(), shutdown_returned.set()),
|
|
daemon=True,
|
|
)
|
|
shutdown_thread.start()
|
|
|
|
returned_before_worker_finished = shutdown_returned.wait(timeout=0.1)
|
|
release_worker.set()
|
|
assert shutdown_returned.wait(timeout=2.0), "shutdown did not return after worker finished"
|
|
shutdown_thread.join(timeout=2.0)
|
|
|
|
assert not returned_before_worker_finished
|
|
assert worker_finished.is_set()
|
|
assert provider._memory_write_threads == set()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("action", "content"),
|
|
[
|
|
("replace", "updated memory"),
|
|
("remove", ""),
|
|
("forget", ""),
|
|
("delete", ""),
|
|
],
|
|
)
|
|
def test_on_memory_write_ignores_non_add_actions(action, content, monkeypatch):
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
uri = "viking://user/peers/hermes/memories/preferences/mem_abc123.md"
|
|
spawned = []
|
|
|
|
class StubThread:
|
|
def __init__(self, *args, **kwargs):
|
|
spawned.append((args, kwargs))
|
|
|
|
def start(self):
|
|
raise AssertionError("non-URI remove should not spawn a mirror thread")
|
|
|
|
import plugins.memory.openviking as _mod
|
|
monkeypatch.setattr(_mod.threading, "Thread", StubThread)
|
|
|
|
provider.on_memory_write(
|
|
action,
|
|
"memory",
|
|
content,
|
|
metadata={"uri": uri, "old_text": "stale fact"},
|
|
)
|
|
|
|
assert spawned == []
|
|
|
|
|
|
def _make_prefetch_provider() -> OpenVikingMemoryProvider:
|
|
provider = OpenVikingMemoryProvider()
|
|
provider._client = MagicMock()
|
|
provider._endpoint = "http://test"
|
|
provider._api_key = ""
|
|
provider._account = "acct"
|
|
provider._user = "usr"
|
|
provider._agent = "hermes"
|
|
return provider
|
|
|
|
|
|
_SESSION_START_LIST_PARAMS = {
|
|
"output": "agent",
|
|
"recursive": True,
|
|
"abs_limit": 512,
|
|
"node_limit": 512,
|
|
}
|
|
|
|
|
|
def _memory_listing(*entries):
|
|
return list(entries)
|
|
|
|
|
|
def _mock_session_start_reads(
|
|
provider: OpenVikingMemoryProvider,
|
|
responses: dict[tuple[str, str], object],
|
|
):
|
|
calls = []
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
request_params = dict(params or {})
|
|
uri = request_params.get("uri", "")
|
|
calls.append((path, request_params, kwargs.get("timeout")))
|
|
response = responses.get((path, uri), "")
|
|
if isinstance(response, Exception):
|
|
raise response
|
|
return {"result": response}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
return calls
|
|
|
|
|
|
def test_session_start_token_estimator_matches_shared_openviking_contract():
|
|
provider = OpenVikingMemoryProvider
|
|
|
|
assert provider._estimate_tokens("abcd") == 1
|
|
assert provider._estimate_tokens("设") == 2
|
|
assert provider._estimate_tokens("设置") == 3
|
|
assert provider._estimate_tokens("设置ab") == 4
|
|
|
|
|
|
def test_prefetch_prepends_session_start_memory_context_once_per_session():
|
|
provider = _make_prefetch_provider()
|
|
calls = _mock_session_start_reads(
|
|
provider,
|
|
{
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"): (
|
|
"User prefers concise answers."
|
|
),
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"): _memory_listing(
|
|
{"isDir": True, "rel_path": "owner"},
|
|
{
|
|
"isDir": False,
|
|
"rel_path": "owner/z-last.md",
|
|
"abstract": " Keep replies compact. ",
|
|
},
|
|
{
|
|
"isDir": False,
|
|
"rel_path": "owner/a-first.md",
|
|
"abstract": "Verify source before editing.",
|
|
},
|
|
{"isDir": False, "rel_path": "owner/ignored.txt", "abstract": "ignore"},
|
|
),
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"): _memory_listing(
|
|
{
|
|
"isDir": False,
|
|
"rel_path": "people/ada.md",
|
|
"abstract": "Ada Lovelace is a collaborator.",
|
|
},
|
|
),
|
|
},
|
|
)
|
|
provider._search_prefetch_context = MagicMock(return_value="- [events]\n recalled context")
|
|
|
|
first = provider.prefetch("What should we recall?", session_id="sid-123")
|
|
second = provider.prefetch("What should we recall?", session_id="sid-123")
|
|
|
|
assert '<user-profile uri="viking://user/memories/profile.md">' in first
|
|
assert "User prefers concise answers." in first
|
|
assert "<available-memories>" in first
|
|
assert "viking://user/memories/preferences/" in first
|
|
assert "owner/z-last.md — Keep replies compact." in first
|
|
assert first.index("owner/a-first.md") < first.index("owner/z-last.md")
|
|
assert "viking://user/memories/entities/" in first
|
|
assert "people/ada.md — Ada Lovelace is a collaborator." in first
|
|
assert "owner/ignored.txt" not in first
|
|
assert "<preferences" not in first
|
|
assert "<entities" not in first
|
|
assert "recalled context" in first
|
|
assert "<user-profile" not in second
|
|
assert "recalled context" in second
|
|
assert [(path, params) for path, params, _timeout in calls] == [
|
|
("/api/v1/content/read", {"uri": "viking://user/memories/profile.md"}),
|
|
(
|
|
"/api/v1/fs/ls",
|
|
{"uri": "viking://user/memories/preferences", **_SESSION_START_LIST_PARAMS},
|
|
),
|
|
(
|
|
"/api/v1/fs/ls",
|
|
{"uri": "viking://user/memories/entities", **_SESSION_START_LIST_PARAMS},
|
|
),
|
|
]
|
|
assert provider._search_prefetch_context.call_count == 2
|
|
|
|
|
|
def test_prefetch_can_return_session_start_memory_for_short_query():
|
|
provider = _make_prefetch_provider()
|
|
_mock_session_start_reads(
|
|
provider,
|
|
{
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"): (
|
|
"User profile is Ada."
|
|
),
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"): [],
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"): [],
|
|
},
|
|
)
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
context = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert "## OpenViking Context" in context
|
|
assert "User profile is Ada." in context
|
|
provider._search_prefetch_context.assert_not_called()
|
|
|
|
|
|
def test_prefetch_session_start_reads_share_one_total_deadline(monkeypatch):
|
|
monkeypatch.setenv("OPENVIKING_RECALL_TIMEOUT_SECONDS", "2")
|
|
monkeypatch.setenv("OPENVIKING_RECALL_REQUEST_TIMEOUT_SECONDS", "2")
|
|
provider = _make_prefetch_provider()
|
|
calls = []
|
|
clock = [100.0]
|
|
monkeypatch.setattr(openviking_module.time, "monotonic", lambda: clock[0])
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
calls.append((path, (params or {}).get("uri", ""), kwargs.get("timeout")))
|
|
clock[0] += 0.75
|
|
if (params or {}).get("uri") == "viking://user/memories/profile.md":
|
|
return {"result": "User profile is Ada."}
|
|
return {"result": []}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
context = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert "User profile is Ada." in context
|
|
assert [(path, uri) for path, uri, _timeout in calls] == [
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"),
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"),
|
|
]
|
|
assert [timeout for _path, _uri, timeout in calls] == pytest.approx([2.0, 1.25, 0.5])
|
|
|
|
|
|
def test_prefetch_retries_session_start_memory_after_empty_failed_attempt():
|
|
provider = _make_prefetch_provider()
|
|
profile_attempts = 0
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
nonlocal profile_attempts
|
|
uri = (params or {}).get("uri", "")
|
|
if uri == "viking://user/memories/profile.md":
|
|
profile_attempts += 1
|
|
if profile_attempts == 1:
|
|
raise RuntimeError("transient profile read failure")
|
|
return {"result": "Recovered user profile."}
|
|
return {"result": ""}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
first = provider.prefetch("hi", session_id="sid-123")
|
|
assert provider._client.get.call_count == 1
|
|
provider._turn_count = 1
|
|
second = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert first == ""
|
|
assert "Recovered user profile." in second
|
|
assert profile_attempts == 2
|
|
|
|
|
|
def test_prefetch_marks_successful_empty_session_start_memory_as_checked():
|
|
provider = _make_prefetch_provider()
|
|
calls = _mock_session_start_reads(
|
|
provider,
|
|
{
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"): "",
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"): [],
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"): [],
|
|
},
|
|
)
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
first = provider.prefetch("hi", session_id="sid-123")
|
|
second = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert first == ""
|
|
assert second == ""
|
|
assert [(path, params["uri"]) for path, params, _timeout in calls] == [
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"),
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"),
|
|
]
|
|
provider._search_prefetch_context.assert_not_called()
|
|
|
|
|
|
def test_prefetch_marks_checked_when_secondary_session_memory_read_fails():
|
|
provider = _make_prefetch_provider()
|
|
calls = []
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
uri = (params or {}).get("uri", "")
|
|
calls.append((path, uri))
|
|
if uri == "viking://user/memories/profile.md":
|
|
return {"result": "User profile is Ada."}
|
|
if uri == "viking://user/memories/entities":
|
|
raise RuntimeError("transient entities listing failure")
|
|
return {"result": []}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
first = provider.prefetch("hi", session_id="sid-123")
|
|
provider._turn_count = 1
|
|
second = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert "User profile is Ada." in first
|
|
assert second == ""
|
|
assert calls == [
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"),
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"),
|
|
]
|
|
provider._search_prefetch_context.assert_not_called()
|
|
|
|
|
|
def test_prefetch_reinjects_after_in_place_compression_same_session():
|
|
provider = _make_prefetch_provider()
|
|
provider._session_id = "sid-123"
|
|
profiles = iter(["Profile before compression.", "Profile after compression."])
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
uri = (params or {}).get("uri", "")
|
|
if uri == "viking://user/memories/profile.md":
|
|
return {"result": next(profiles)}
|
|
return {"result": []}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
first = provider.prefetch("hi", session_id="sid-123")
|
|
provider._turn_count = 3
|
|
provider.on_session_switch("sid-123", reason="compression")
|
|
second = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert "Profile before compression." in first
|
|
assert "Profile after compression." in second
|
|
|
|
|
|
def test_prefetch_reinjects_for_new_session_id():
|
|
provider = _make_prefetch_provider()
|
|
profiles = iter(["Session A profile.", "Session B profile."])
|
|
|
|
def fake_get(path, params=None, **kwargs):
|
|
uri = (params or {}).get("uri", "")
|
|
if uri == "viking://user/memories/profile.md":
|
|
return {"result": next(profiles)}
|
|
return {"result": []}
|
|
|
|
provider._client.get.side_effect = fake_get
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
first = provider.prefetch("hi", session_id="sid-a")
|
|
second = provider.prefetch("hi", session_id="sid-b")
|
|
|
|
assert "Session A profile." in first
|
|
assert "Session B profile." in second
|
|
|
|
|
|
def test_prefetch_degrades_cleanly_when_profile_is_definitively_missing():
|
|
provider = _make_prefetch_provider()
|
|
_mock_session_start_reads(
|
|
provider,
|
|
{
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"): (
|
|
openviking_module._OpenVikingHTTPError("not found", 404)
|
|
),
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"): _memory_listing(
|
|
{
|
|
"isDir": False,
|
|
"rel_path": "owner/review.md",
|
|
"abstract": "Likes source-backed answers.",
|
|
},
|
|
),
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"): [],
|
|
},
|
|
)
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
context = provider.prefetch("hi", session_id="sid-123")
|
|
|
|
assert "Likes source-backed answers." in context
|
|
assert "<user-profile" not in context
|
|
assert "viking://user/memories/preferences/" in context
|
|
|
|
|
|
def test_session_start_memory_context_respects_total_budget_and_preserves_profile_tail(monkeypatch):
|
|
monkeypatch.setenv("OPENVIKING_PROFILE_TOKEN_BUDGET", "6000")
|
|
provider = _make_prefetch_provider()
|
|
long_profile = "\n".join(
|
|
["Profile head: user is Ada."]
|
|
+ [f"profile middle {i}: {'设' * 30}" for i in range(300)]
|
|
+ ["Profile tail: recent work is OpenViking."]
|
|
)
|
|
listing = _memory_listing(*[
|
|
{
|
|
"isDir": False,
|
|
"rel_path": f"owner/topic-{index:02d}.md",
|
|
"abstract": "偏好经过源码验证的答案" * 6,
|
|
}
|
|
for index in range(700)
|
|
])
|
|
_mock_session_start_reads(
|
|
provider,
|
|
{
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"): long_profile,
|
|
("/api/v1/fs/ls", "viking://user/memories/preferences"): listing,
|
|
("/api/v1/fs/ls", "viking://user/memories/entities"): listing,
|
|
},
|
|
)
|
|
provider._search_prefetch_context = MagicMock(return_value="should not run")
|
|
|
|
context = provider.prefetch("hi", session_id="sid-budget")
|
|
block = context.removeprefix("## OpenViking Context\n")
|
|
|
|
assert provider._estimate_tokens(block) <= 6000
|
|
assert "Profile head: user is Ada." in block
|
|
assert "Profile tail: recent work is OpenViking." in block
|
|
assert "[profile middle elided]" in block
|
|
assert "<available-memories>" in block
|
|
assert "viking_profile" not in block
|
|
|
|
|
|
def test_prefetch_does_not_auto_inject_memory_overview_when_profile_missing():
|
|
provider = _make_prefetch_provider()
|
|
calls = _mock_session_start_reads(
|
|
provider,
|
|
{
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"): RuntimeError(
|
|
"transient profile failure"
|
|
),
|
|
},
|
|
)
|
|
provider._search_prefetch_context = MagicMock(return_value="- [events]\n recalled context")
|
|
|
|
context = provider.prefetch("What should we recall?", session_id="sid-123")
|
|
|
|
assert "<user-profile" not in context
|
|
assert "recalled context" in context
|
|
assert [(path, params["uri"]) for path, params, _timeout in calls] == [
|
|
("/api/v1/content/read", "viking://user/memories/profile.md"),
|
|
]
|
|
provider._search_prefetch_context.assert_called_once_with(
|
|
"What should we recall?",
|
|
session_id="sid-123",
|
|
)
|
|
|
|
|
|
def test_queue_prefetch_is_noop_for_openviking_recall(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
constructed_clients = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
constructed_clients.append((a, kw))
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider.queue_prefetch("anything", session_id="sid-123")
|
|
|
|
assert constructed_clients == []
|
|
|
|
|
|
def test_prefetch_sends_contract_safe_memory_context_payload(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_calls = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured_calls.append((path, payload))
|
|
return {"result": {"memories": [], "resources": []}}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider.prefetch("anything")
|
|
|
|
assert captured_calls == [
|
|
(
|
|
"/api/v1/search/find",
|
|
{
|
|
"query": "anything",
|
|
"limit": 24,
|
|
"score_threshold": 0,
|
|
"context_type": "memory",
|
|
},
|
|
)
|
|
]
|
|
payload = captured_calls[0][1]
|
|
assert "top_k" not in payload
|
|
assert "mode" not in payload
|
|
assert "target_uri" not in payload
|
|
|
|
|
|
def test_prefetch_uses_session_search_when_session_id_available(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_calls = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured_calls.append((path, payload))
|
|
return {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/events/mem_1.md",
|
|
"score": 0.9,
|
|
"abstract": "session-aware memory",
|
|
},
|
|
],
|
|
"resources": [],
|
|
"skills": [],
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
result = provider.prefetch("anything", session_id="sid-123")
|
|
|
|
assert captured_calls == [
|
|
(
|
|
"/api/v1/search/search",
|
|
{
|
|
"query": "anything",
|
|
"limit": 24,
|
|
"score_threshold": 0,
|
|
"context_type": "memory",
|
|
"session_id": "sid-123",
|
|
},
|
|
)
|
|
]
|
|
payload = captured_calls[0][1]
|
|
assert "top_k" not in payload
|
|
assert "mode" not in payload
|
|
assert "target_uri" not in payload
|
|
assert "session-aware memory" in result
|
|
|
|
|
|
def test_prefetch_falls_back_to_find_when_session_search_fails(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_calls = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured_calls.append((path, payload))
|
|
if path == "/api/v1/search/search":
|
|
raise RuntimeError("session unavailable")
|
|
return {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/events/mem_2.md",
|
|
"score": 0.8,
|
|
"abstract": "non-session fallback",
|
|
},
|
|
],
|
|
"resources": [],
|
|
"skills": [],
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
result = provider.prefetch("anything", session_id="sid-123")
|
|
|
|
assert captured_calls == [
|
|
(
|
|
"/api/v1/search/search",
|
|
{
|
|
"query": "anything",
|
|
"limit": 24,
|
|
"score_threshold": 0,
|
|
"context_type": "memory",
|
|
"session_id": "sid-123",
|
|
},
|
|
),
|
|
(
|
|
"/api/v1/search/find",
|
|
{
|
|
"query": "anything",
|
|
"limit": 24,
|
|
"score_threshold": 0,
|
|
"context_type": "memory",
|
|
},
|
|
),
|
|
]
|
|
for _path, payload in captured_calls:
|
|
assert "top_k" not in payload
|
|
assert "mode" not in payload
|
|
assert "target_uri" not in payload
|
|
assert "non-session fallback" in result
|
|
|
|
|
|
def test_prefetch_budget_exhaustion_skips_find_fallback_log(caplog):
|
|
class StubClient:
|
|
def post(self, path, payload=None, **kwargs):
|
|
raise AssertionError("local budget exhaustion should not issue HTTP calls")
|
|
|
|
with caplog.at_level("DEBUG", logger=openviking_module.__name__):
|
|
with pytest.raises(TimeoutError):
|
|
OpenVikingMemoryProvider._post_prefetch_search(
|
|
StubClient(),
|
|
"anything",
|
|
"sid-123",
|
|
limit=24,
|
|
context_type="memory",
|
|
deadline=time.monotonic() - 1.0,
|
|
request_timeout=4.0,
|
|
)
|
|
|
|
assert "falling back to search/find" not in caplog.text
|
|
|
|
|
|
def test_prefetch_reads_l2_content_and_ignores_skills_by_default(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_reads = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
return {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/events/mem_3.md",
|
|
"score": 0.9,
|
|
"level": 2,
|
|
"category": "events",
|
|
"abstract": "short abstract",
|
|
},
|
|
],
|
|
"resources": [],
|
|
"skills": [
|
|
{
|
|
"uri": "viking://user/skills/release-triage",
|
|
"score": 0.7,
|
|
"abstract": "skill context",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
def get(self, path, params=None, **kwargs):
|
|
captured_reads.append((path, params or {}))
|
|
return {"result": {"content": "full memory content\nwith useful context"}}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
context = provider.prefetch("anything")
|
|
|
|
assert captured_reads == [
|
|
(
|
|
"/api/v1/content/read",
|
|
{"uri": "viking://user/peers/hermes/memories/events/mem_3.md"},
|
|
)
|
|
]
|
|
assert "full memory content" in context
|
|
assert "short abstract" not in context
|
|
assert "skill context" not in context
|
|
|
|
|
|
def test_prefetch_reads_empty_abstract_content_within_budget(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_reads = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
return {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/one.md",
|
|
"score": 0.9,
|
|
"abstract": "",
|
|
},
|
|
],
|
|
"resources": [],
|
|
"skills": [],
|
|
}
|
|
}
|
|
|
|
def get(self, path, params=None, **kwargs):
|
|
captured_reads.append((path, params or {}))
|
|
uri = (params or {}).get("uri", "")
|
|
return {"result": f"content for {uri}"}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
context = provider.prefetch("anything")
|
|
|
|
assert [params["uri"] for _path, params in captured_reads] == [
|
|
"viking://user/peers/hermes/memories/one.md",
|
|
]
|
|
assert (
|
|
"content for viking://user/peers/hermes/memories/one.md"
|
|
in context
|
|
)
|
|
|
|
|
|
def test_prefetch_caps_full_content_reads(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_reads = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
return {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": f"viking://user/peers/hermes/memories/events/mem_{idx}.md",
|
|
"score": 0.9 - (idx * 0.01),
|
|
"level": 2,
|
|
"category": "events",
|
|
"abstract": f"short abstract {idx}",
|
|
}
|
|
for idx in range(6)
|
|
],
|
|
"resources": [],
|
|
"skills": [],
|
|
}
|
|
}
|
|
|
|
def get(self, path, params=None, **kwargs):
|
|
captured_reads.append((path, params or {}))
|
|
uri = (params or {}).get("uri", "")
|
|
return {"result": {"content": f"full content for {uri}"}}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
context = provider.prefetch("anything")
|
|
|
|
assert len(captured_reads) == 2
|
|
assert "full content for viking://user/peers/hermes/memories/events/mem_0.md" in context
|
|
assert "full content for viking://user/peers/hermes/memories/events/mem_1.md" in context
|
|
assert "short abstract 2" in context
|
|
|
|
|
|
def test_prefetch_uses_bounded_http_timeouts(monkeypatch):
|
|
provider = _make_prefetch_provider()
|
|
|
|
captured_post_kwargs = []
|
|
captured_get_kwargs = []
|
|
|
|
class StubClient:
|
|
def __init__(self, *a, **kw):
|
|
pass
|
|
|
|
def post(self, path, payload=None, **kwargs):
|
|
captured_post_kwargs.append(kwargs)
|
|
return {
|
|
"result": {
|
|
"memories": [
|
|
{
|
|
"uri": "viking://user/peers/hermes/memories/events/mem_timeout.md",
|
|
"score": 0.9,
|
|
"level": 2,
|
|
"category": "events",
|
|
"abstract": "short abstract",
|
|
},
|
|
],
|
|
"resources": [],
|
|
"skills": [],
|
|
}
|
|
}
|
|
|
|
def get(self, path, params=None, **kwargs):
|
|
captured_get_kwargs.append(kwargs)
|
|
return {"result": {"content": "full memory content"}}
|
|
|
|
monkeypatch.setattr(openviking_module, "_VikingClient", StubClient)
|
|
|
|
provider.prefetch("anything", session_id="sid-123")
|
|
|
|
assert 0 < captured_post_kwargs[0]["timeout"] < openviking_module._TIMEOUT
|
|
assert 0 < captured_get_kwargs[0]["timeout"] < openviking_module._TIMEOUT
|