Files
hermes-agent/tests/hermes_cli/test_memory_providers.py
Brooklyn Nicholson e48d18c8e2 feat(desktop): schema-driven memory-provider config surface
Make the desktop memory settings dynamic instead of hardcoded per
provider. The dropdown is now populated from discover_memory_providers()
(bundled + user-installed + pip) rather than a static enum, and each
provider's config panel is derived from its own get_config_schema() —
the same declaration `hermes memory setup` uses — so adding or porting a
provider is pure declaration with no bespoke UI, conditional, or
endpoint.

- memory_providers.py: reworked from a hand-written Hindsight registry
  into a pure adapter (describe_provider + coerce_value) that normalizes
  a provider's raw schema into typed fields — secret(+env), select,
  boolean, typed text — carrying `when` conditionals, url, and required.
- MemoryProvider ABC: add optional read_current_config() (default {}),
  the read-back mirror of save_config(). Ported mem0, hindsight, honcho,
  holographic; the rest fall back to schema defaults safely.
- web_server GET/PUT /api/memory/providers/{name}/config now load the
  live provider, derive its schema, write non-secrets via the provider's
  own save_config() (each keeps its native storage), persist secrets to
  the env store, and `when`-gate validation so hidden fields aren't
  required or written. Secrets stay write-only (is_set only).
- ProviderConfigPanel: `when`-conditional visibility (handles Hindsight's
  mode-gated duplicate keys), boolean toggle, and credential url links.
  Dropdown driven by getMemoryStatus(); hardcoded enum removed.

Tests assert the mapping contract and endpoint behavior (schema
derivation, save-via-save_config, secret-never-returned, when-gating,
select rejection) against real bundled providers rather than a snapshot
of a hardcoded list.
2026-06-18 17:16:13 -05:00

130 lines
3.8 KiB
Python

"""Tests for the schema-driven memory-provider config adapter.
These assert the *mapping contract* (how a provider's declared
``get_config_schema()`` becomes the normalized desktop field shape) and the
write-time coercion — not a snapshot of any particular provider's fields, which
are free to change.
"""
import pytest
from hermes_cli.memory_providers import (
KIND_BOOLEAN,
KIND_SECRET,
KIND_SELECT,
KIND_TEXT,
VALUE_BOOL,
VALUE_INT,
coerce_value,
describe_provider,
)
def _by_key(provider):
return {f.key: f for f in provider.fields}
def test_secret_field_maps_to_secret_kind_bound_to_env():
provider = describe_provider(
"x",
[{"key": "api_key", "secret": True, "env_var": "X_API_KEY", "url": "https://x"}],
)
field = _by_key(provider)["api_key"]
assert field.kind == KIND_SECRET
assert field.is_secret is True
assert field.env_key == "X_API_KEY"
assert field.url == "https://x"
def test_choices_map_to_select_options():
provider = describe_provider(
"x", [{"key": "mode", "default": "cloud", "choices": ["cloud", "local"]}]
)
field = _by_key(provider)["mode"]
assert field.kind == KIND_SELECT
assert field.allowed_values() == {"cloud", "local"}
def test_bool_default_maps_to_boolean_kind():
provider = describe_provider("x", [{"key": "auto", "default": True}])
field = _by_key(provider)["auto"]
assert field.kind == KIND_BOOLEAN
assert field.value_type == VALUE_BOOL
assert field.default == "true"
def test_int_default_maps_to_text_with_int_value_type():
provider = describe_provider("x", [{"key": "tokens", "default": 4096}])
field = _by_key(provider)["tokens"]
assert field.kind == KIND_TEXT
assert field.value_type == VALUE_INT
assert field.default == "4096"
def test_when_clause_is_carried_through():
provider = describe_provider(
"x",
[
{"key": "mode", "default": "cloud", "choices": ["cloud", "local"]},
{"key": "api_url", "default": "u", "when": {"mode": "cloud"}},
],
)
api_url = _by_key(provider)["api_url"]
assert api_url.when == (("mode", "cloud"),)
assert api_url.when_matches({"mode": "cloud"}) is True
assert api_url.when_matches({"mode": "local"}) is False
def test_every_field_has_a_known_kind():
# Invariant: the adapter never emits a field the renderer can't handle.
provider = describe_provider(
"x",
[
{"key": "a", "secret": True, "env_var": "A"},
{"key": "b", "choices": ["1", "2"]},
{"key": "c", "default": True},
{"key": "d", "default": "text"},
],
)
known = {KIND_TEXT, KIND_SELECT, KIND_SECRET, KIND_BOOLEAN}
assert provider.fields # non-empty
assert all(f.kind in known for f in provider.fields)
def test_malformed_entries_are_skipped_not_fatal():
provider = describe_provider("x", ["nope", {}, {"key": ""}, {"key": "ok"}])
assert _by_key(provider).keys() == {"ok"}
def test_coerce_rejects_value_outside_select_options():
provider = describe_provider("x", [{"key": "mode", "choices": ["cloud", "local"]}])
field = _by_key(provider)["mode"]
with pytest.raises(ValueError):
coerce_value(field, "bogus")
def test_coerce_casts_bool_and_int_to_native_types():
provider = describe_provider(
"x", [{"key": "auto", "default": True}, {"key": "tokens", "default": 4096}]
)
fields = _by_key(provider)
assert coerce_value(fields["auto"], "true") is True
assert coerce_value(fields["auto"], "off") is False
assert coerce_value(fields["tokens"], "8000") == 8000
def test_coerce_empty_falls_back_to_default():
provider = describe_provider("x", [{"key": "name", "default": "hermes"}])
field = _by_key(provider)["name"]
assert coerce_value(field, "") == "hermes"