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

1713 lines
69 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the Hindsight memory provider plugin.
Tests cover config loading, tool handlers (tags, max_tokens, types),
prefetch (auto_recall, preamble, query truncation), sync_turn (auto_retain,
turn counting, tags), and schema completeness.
"""
import json
import os
import re
import stat
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from zoneinfo import ZoneInfo
import pytest
from hermes_cli.memory_setup import _CANCELLED
from plugins.memory.hindsight import (
HindsightMemoryProvider,
RECALL_SCHEMA,
REFLECT_SCHEMA,
RETAIN_SCHEMA,
_load_config,
_load_simple_env,
_build_embedded_profile_env,
_normalize_observation_scopes,
_normalize_retain_tags,
_resolve_bank_id_template,
_sanitize_bank_segment,
_WRITER_SENTINEL,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clean_env(tmp_path, monkeypatch):
"""Ensure no stale env vars or Windows home state leak between tests."""
for key in (
"HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID",
"HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_TIMEOUT",
"HINDSIGHT_IDLE_TIMEOUT", "HINDSIGHT_LLM_API_KEY",
"HINDSIGHT_RETAIN_TAGS", "HINDSIGHT_RETAIN_OBSERVATION_SCOPES",
"HINDSIGHT_RETAIN_SOURCE",
"HINDSIGHT_RETAIN_USER_PREFIX", "HINDSIGHT_RETAIN_ASSISTANT_PREFIX",
):
monkeypatch.delenv(key, raising=False)
# On Windows pathlib.Path.home() resolves USERPROFILE/HOMEDRIVE+HOMEPATH,
# not the POSIX HOME alias that these tests historically monkeypatched.
# Patch the actual API and keep all legacy profile writes in tmp_path.
isolated_home = tmp_path / "user-home"
monkeypatch.setattr(Path, "home", classmethod(lambda cls: isolated_home))
def _make_mock_client():
"""Create a mock Hindsight client with async methods."""
async def _aretain(
bank_id,
content,
timestamp=None,
context=None,
document_id=None,
metadata=None,
entities=None,
tags=None,
update_mode=None,
retain_async=None,
):
return SimpleNamespace(ok=True)
client = MagicMock()
client.aretain = AsyncMock(side_effect=_aretain)
client.arecall = AsyncMock(
return_value=SimpleNamespace(
results=[
SimpleNamespace(text="Memory 1"),
SimpleNamespace(text="Memory 2"),
]
)
)
client.areflect = AsyncMock(
return_value=SimpleNamespace(text="Synthesized answer")
)
client.aretain_batch = AsyncMock()
client.aclose = AsyncMock()
return client
def _provider_for_mode(tmp_path, monkeypatch, mode: str):
"""Create an initialized provider without pre-seeding its client."""
config = {
"mode": mode,
"apiKey": "test-key",
"api_url": "http://localhost:9999",
"bank_id": "test-bank",
"budget": "mid",
"memory_mode": "hybrid",
}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr(
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
)
provider = HindsightMemoryProvider()
provider.initialize(session_id="test-session", hermes_home=str(tmp_path), platform="cli")
return provider
def _assert_cloud_client_lazy_installed_before_import(tmp_path, monkeypatch, mode: str):
"""Cloud/local-external clients must ensure lazy deps before importing."""
import builtins
provider = _provider_for_mode(tmp_path, monkeypatch, mode)
ensure_calls = []
def fake_ensure(feature, prompt=True):
ensure_calls.append((feature, prompt))
class FakeHindsight:
def __init__(self, **kwargs):
self.kwargs = kwargs
real_import = builtins.__import__
def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "hindsight_client":
if ensure_calls != [("memory.hindsight", False)]:
raise ModuleNotFoundError("No module named 'hindsight_client'")
return SimpleNamespace(Hindsight=FakeHindsight)
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr("tools.lazy_deps.ensure", fake_ensure)
monkeypatch.setattr(builtins, "__import__", guarded_import)
client = provider._get_client()
assert ensure_calls == [("memory.hindsight", False)]
assert isinstance(client, FakeHindsight)
assert client.kwargs == {
"base_url": "http://localhost:9999",
"timeout": 120.0,
"api_key": "test-key",
}
class _FakeSessionDB:
def __init__(self, messages=None):
self._messages = list(messages or [])
def get_messages_as_conversation(self, session_id):
return list(self._messages)
@pytest.fixture()
def provider(tmp_path, monkeypatch):
"""Create an initialized HindsightMemoryProvider with a mock client."""
config = {
"mode": "cloud",
"apiKey": "test-key",
"api_url": "http://localhost:9999",
"bank_id": "test-bank",
"budget": "mid",
"memory_mode": "hybrid",
}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr(
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
)
p = HindsightMemoryProvider()
p.initialize(session_id="test-session", hermes_home=str(tmp_path), platform="cli")
p._client = _make_mock_client()
return p
@pytest.fixture()
def provider_with_config(tmp_path, monkeypatch):
"""Create a provider factory that accepts custom config overrides."""
def _make(**overrides):
config = {
"mode": "cloud",
"apiKey": "test-key",
"api_url": "http://localhost:9999",
"bank_id": "test-bank",
"budget": "mid",
"memory_mode": "hybrid",
}
config.update(overrides)
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr(
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
)
p = HindsightMemoryProvider()
p.initialize(session_id="test-session", hermes_home=str(tmp_path), platform="cli")
p._client = _make_mock_client()
return p
return _make
def test_normalize_retain_tags_accepts_csv_and_dedupes():
assert _normalize_retain_tags("agent:fakeassistantname, source_system:hermes-agent, agent:fakeassistantname") == [
"agent:fakeassistantname",
"source_system:hermes-agent",
]
# ---------------------------------------------------------------------------
# Schema tests
# ---------------------------------------------------------------------------
class TestSchemas:
def test_retain_schema_has_content(self):
assert RETAIN_SCHEMA["name"] == "hindsight_retain"
assert "content" in RETAIN_SCHEMA["parameters"]["properties"]
assert "tags" in RETAIN_SCHEMA["parameters"]["properties"]
assert "content" in RETAIN_SCHEMA["parameters"]["required"]
def test_get_tool_schemas_returns_three(self, provider):
schemas = provider.get_tool_schemas()
assert len(schemas) == 3
names = {s["name"] for s in schemas}
assert names == {"hindsight_retain", "hindsight_recall", "hindsight_reflect"}
def test_context_mode_returns_no_tools(self, provider_with_config):
p = provider_with_config(memory_mode="context")
assert p.get_tool_schemas() == []
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
class TestConfig:
def test_cloud_client_lazy_installs_dependency_before_import(self, tmp_path, monkeypatch):
_assert_cloud_client_lazy_installed_before_import(tmp_path, monkeypatch, "cloud")
def test_default_values(self, provider):
assert provider._auto_retain is True
assert provider._auto_recall is True
assert provider._retain_every_n_turns == 1
assert provider._recall_max_tokens == 4096
assert provider._recall_max_input_chars == 800
assert provider._tags is None
assert provider._observation_scopes is None
assert provider._recall_tags is None
# Default recall narrowed to observation-only; world/experience are
# aggregate facts that often crowd out concrete-event signal during
# auto-recall. Users opt back in via the recall_types config key.
assert provider._recall_types == ["observation"]
assert provider._bank_mission == ""
assert provider._bank_retain_mission is None
assert provider._retain_context == "conversation between Hermes Agent and the User"
def test_recall_types_default_is_observation_only(self, provider):
"""Auto-recall must filter to observation by default."""
assert provider._recall_types == ["observation"]
def test_observation_scopes_keyword_config(self, provider_with_config):
p = provider_with_config(observation_scopes="per_tag")
assert p._observation_scopes == "per_tag"
def test_custom_config_values(self, provider_with_config):
p = provider_with_config(
retain_tags=["tag1", "tag2"],
retain_source="hermes",
retain_user_prefix="User (fakeusername)",
retain_assistant_prefix="Assistant (fakeassistantname)",
recall_tags=["recall-tag"],
recall_tags_match="all",
auto_retain=False,
auto_recall=False,
retain_every_n_turns=3,
retain_context="custom-ctx",
bank_retain_mission="Extract key facts",
recall_max_tokens=2048,
recall_types=["world", "experience"],
recall_prompt_preamble="Custom preamble:",
recall_max_input_chars=500,
bank_mission="Test agent mission",
)
assert p._tags == ["tag1", "tag2"]
assert p._retain_tags == ["tag1", "tag2"]
assert p._retain_source == "hermes"
assert p._retain_user_prefix == "User (fakeusername)"
assert p._retain_assistant_prefix == "Assistant (fakeassistantname)"
assert p._recall_tags == ["recall-tag"]
assert p._recall_tags_match == "all"
assert p._auto_retain is False
assert p._auto_recall is False
assert p._retain_every_n_turns == 3
assert p._retain_context == "custom-ctx"
assert p._bank_retain_mission == "Extract key facts"
assert p._recall_max_tokens == 2048
assert p._recall_types == ["world", "experience"]
assert p._recall_prompt_preamble == "Custom preamble:"
assert p._recall_max_input_chars == 500
assert p._bank_mission == "Test agent mission"
def test_retain_source_defaults_empty(self, provider):
# Opt-in per AGENTS.md: no attribution tag ships by default.
assert provider._retain_source == ""
def test_retain_source_absent_from_metadata_by_default(self, provider):
# metadata.source is stamped only when the user sets retain_source.
meta = provider._build_metadata(message_count=2, turn_index=1)
assert "source" not in meta
def test_retain_source_user_override_wins(self, provider_with_config):
# Users can still opt in explicitly (config key / env var).
p = provider_with_config(retain_source="cogoport")
assert p._retain_source == "cogoport"
assert p._build_metadata(message_count=2, turn_index=1)["source"] == "cogoport"
def test_embedded_profile_env_includes_idle_timeout_from_config(self):
env = _build_embedded_profile_env({
"llm_provider": "openai",
"llm_model": "gpt-4o-mini",
"idle_timeout": 0,
})
assert env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] == "0"
def test_get_client_passes_idle_timeout_to_hindsight_embedded(self, monkeypatch):
captured = {}
class FakeHindsightEmbedded:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setitem(sys.modules, "hindsight", SimpleNamespace(HindsightEmbedded=FakeHindsightEmbedded))
monkeypatch.setattr("plugins.memory.hindsight._check_local_runtime", lambda: (True, ""))
p = HindsightMemoryProvider()
p._mode = "local_embedded"
p._config = {
"profile": "hermes",
"llm_provider": "openai_compatible",
"llm_api_key": "test-key",
"llm_model": "test-model",
"idle_timeout": 0,
}
p._llm_base_url = "http://localhost:8060/v1"
p._get_client()
assert captured["idle_timeout"] == 0
assert captured["llm_provider"] == "openai"
class TestPostSetup:
def test_setup_cancel_at_mode_picker_writes_nothing(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home)
save_config = MagicMock()
which = MagicMock(return_value="/usr/bin/uv")
run = MagicMock()
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: _CANCELLED)
monkeypatch.setattr("shutil.which", which)
monkeypatch.setattr("subprocess.run", run)
monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run")))
monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run")))
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}})
save_config.assert_not_called()
which.assert_not_called()
run.assert_not_called()
assert not (hermes_home / ".env").exists()
assert not (hermes_home / "hindsight" / "config.json").exists()
assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists()
def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
selections = iter([1, 0]) # local_embedded, openai
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("builtins.input", lambda prompt="": "")
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("getpass.getpass", lambda prompt="": "sk-local-test")
saved_configs = []
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_configs.append(cfg.copy()))
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {}})
assert saved_configs[-1]["memory"]["provider"] == "hindsight"
env_text = (hermes_home / ".env").read_text()
assert "HINDSIGHT_LLM_API_KEY=sk-local-test\n" in env_text
assert "HINDSIGHT_TIMEOUT=120\n" in env_text
assert "HINDSIGHT_IDLE_TIMEOUT=300\n" in env_text
profile_env = user_home / ".hindsight" / "profiles" / "hermes.env"
assert profile_env.exists()
assert profile_env.read_text() == (
"HINDSIGHT_API_LLM_PROVIDER=openai\n"
"HINDSIGHT_API_LLM_API_KEY=sk-local-test\n"
"HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n"
"HINDSIGHT_API_LOG_LEVEL=info\n"
"HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT=300\n"
)
# ---------------------------------------------------------------------------
# Tool handler tests
# ---------------------------------------------------------------------------
class TestToolHandlers:
def test_retain_success(self, provider):
result = json.loads(provider.handle_tool_call(
"hindsight_retain", {"content": "user likes dark mode"}
))
assert result["result"] == "Memory stored successfully."
provider._client.aretain_batch.assert_called_once()
call_kwargs = provider._client.aretain_batch.call_args.kwargs
assert call_kwargs["bank_id"] == "test-bank"
item = call_kwargs["items"][0]
assert item["content"] == "user likes dark mode"
# bank_id/retain_async are call-level args, never item keys.
assert "bank_id" not in item
assert "retain_async" not in item
def test_retain_defaults_item_timestamp_when_no_occurred_at(self, provider, monkeypatch):
event_time = datetime(2026, 8, 24, 9, 30, tzinfo=ZoneInfo("America/Los_Angeles"))
monkeypatch.setattr("plugins.memory.hindsight._hermes_now", lambda: event_time)
result = json.loads(provider.handle_tool_call(
"hindsight_retain", {"content": "user likes dark mode"}
))
assert result["result"] == "Memory stored successfully."
item = provider._client.aretain_batch.call_args.kwargs["items"][0]
# Non-temporal retains still carry a defaulted event timestamp so the
# server can resolve any relative time phrases (#93568).
assert item["timestamp"] == event_time.isoformat(timespec="seconds")
def test_retain_threads_explicit_occurred_at_into_item_timestamp(self, provider):
result = json.loads(provider.handle_tool_call(
"hindsight_retain",
{"content": "user visited Paris", "occurred_at": "2026-03-03"},
))
assert result["result"] == "Memory stored successfully."
item = provider._client.aretain_batch.call_args.kwargs["items"][0]
assert item["timestamp"] == "2026-03-03"
def test_retain_ignores_blank_occurred_at(self, provider, monkeypatch):
event_time = datetime(2026, 8, 24, 9, 30, tzinfo=ZoneInfo("America/Los_Angeles"))
monkeypatch.setattr("plugins.memory.hindsight._hermes_now", lambda: event_time)
json.loads(provider.handle_tool_call(
"hindsight_retain", {"content": "hello", "occurred_at": " "}
))
item = provider._client.aretain_batch.call_args.kwargs["items"][0]
assert item["timestamp"] == event_time.isoformat(timespec="seconds")
def test_build_retain_kwargs_accepts_explicit_occurred_at(self, provider):
item = provider._build_retain_kwargs("dinner with Sam", occurred_at="2026-08-20T19:00:00+02:00")
assert item["timestamp"] == "2026-08-20T19:00:00+02:00"
def test_retain_schema_exposes_occurred_at(self):
from plugins.memory.hindsight import RETAIN_SCHEMA
props = RETAIN_SCHEMA["parameters"]["properties"]
assert "occurred_at" in props
assert props["occurred_at"]["type"] == "string"
# The description must steer the model to pass event times.
assert "event" in props["occurred_at"]["description"].lower()
assert "occurred_at" not in RETAIN_SCHEMA["parameters"]["required"]
def test_recall_success(self, provider):
result = json.loads(provider.handle_tool_call(
"hindsight_recall", {"query": "dark mode"}
))
assert "Memory 1" in result["result"]
assert "Memory 2" in result["result"]
def test_reflect_success(self, provider):
result = json.loads(provider.handle_tool_call(
"hindsight_reflect", {"query": "summarize"}
))
assert result["result"] == "Synthesized answer"
def test_unknown_tool(self, provider):
result = json.loads(provider.handle_tool_call(
"hindsight_unknown", {}
))
assert "error" in result
def test_local_embedded_recall_reconnects_after_idle_shutdown(self, provider, monkeypatch):
first_client = _make_mock_client()
first_client.arecall.side_effect = RuntimeError("Cannot connect to host 127.0.0.1:8888")
second_client = _make_mock_client()
second_client.arecall.return_value = SimpleNamespace(
results=[SimpleNamespace(text="Recovered memory")]
)
clients = iter([first_client, second_client])
provider._mode = "local_embedded"
provider._client = first_client
monkeypatch.setattr(provider, "_get_client", lambda: next(clients))
result = json.loads(provider.handle_tool_call(
"hindsight_recall", {"query": "test"}
))
assert result["result"] == "1. Recovered memory"
assert provider._client is second_client
first_client.arecall.assert_called_once()
second_client.arecall.assert_called_once()
# ---------------------------------------------------------------------------
# Prefetch tests
# ---------------------------------------------------------------------------
class TestPrefetch:
def test_prefetch_returns_empty_when_no_result(self, provider):
assert provider.prefetch("test") == ""
def test_recall_sync_defaults_off(self, provider):
assert provider._recall_sync is False
def test_recall_sync_recalls_current_query_synchronously(self, provider_with_config):
# recall_sync=True: prefetch() must do a live recall against the
# *current* query (not read a previously queued buffer). #5820
p = provider_with_config(recall_sync=True)
captured = {}
def _capture_recall(**kwargs):
captured["query"] = kwargs.get("query", "")
return SimpleNamespace(results=[SimpleNamespace(text="fresh memory")])
p._client.arecall = AsyncMock(side_effect=_capture_recall)
# Nothing pre-buffered — proves the result comes from a live recall.
assert p._prefetch_result == ""
result = p.prefetch("fix tests")
assert captured["query"] == "fix tests" # current query, not ignored
assert "fresh memory" in result
p._client.arecall.assert_called_once()
def test_recall_sync_skips_background_queue(self, provider_with_config):
# With sync recall there's nothing to prime in the background.
p = provider_with_config(recall_sync=True)
p.queue_prefetch("anything")
assert p._prefetch_thread is None
def test_async_default_ignores_current_query_and_reads_buffer(self, provider):
# Default (recall_sync off): prefetch returns the buffered result and
# does NOT issue a live recall for the current query.
provider._prefetch_result = "- buffered from previous turn"
result = provider.prefetch("a totally different current query")
assert "buffered from previous turn" in result
provider._client.arecall.assert_not_called()
def test_queue_prefetch_skipped_in_tools_mode(self, provider_with_config):
p = provider_with_config(memory_mode="tools")
p.queue_prefetch("test")
# Should not start a thread
assert p._prefetch_thread is None
def test_prefetch_waits_for_pending_retain_before_recall(self, provider):
"""The background prefetch must wait for queued retains to drain so the
next turn's recall observes the just-completed turn (no retain race)."""
import threading
order = []
release = threading.Event()
async def _slow_retain(*args, **kwargs):
release.wait(timeout=5.0)
order.append("retain")
async def _recall(**kwargs):
order.append("recall")
return SimpleNamespace(results=[SimpleNamespace(text="m")])
provider._client.aretain_batch = AsyncMock(side_effect=_slow_retain)
provider._client.arecall = AsyncMock(side_effect=_recall)
# Enqueue a slow retain, then immediately queue the next-turn prefetch.
provider.sync_turn("hello", "world")
provider.queue_prefetch("next turn query")
# Let the prefetch thread start and reach the drain barrier.
time.sleep(0.2)
assert order == [], "recall ran before the pending retain drained"
# Release the retain; the prefetch should now proceed AFTER it.
release.set()
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=5.0)
provider._retain_queue.join()
assert order and order[0] == "retain"
assert "recall" in order
def test_prefetch_wait_for_retain_can_be_disabled(self, provider_with_config):
p = provider_with_config(prefetch_waits_for_retain=False)
p._client = _make_mock_client()
assert p._prefetch_waits_for_retain is False
class TestPrefetchServerRetainVisibility:
"""PR #62871 review follow-up: draining the local writer queue is not a
read-after-write signal for async retains. With ``retain_async=True`` the
server accepts the write and returns an ``operation_id`` that stays
``pending`` until the write is durable/recall-visible. The background
prefetch must gate on server-side operation completion, not just the local
queue, before recalling.
"""
def _client_with_ops(self, statuses):
"""Mock client whose aretain_batch returns an async operation_id and
whose operations.get_operation_status yields *statuses* in order
(last value repeats)."""
client = _make_mock_client()
client.aretain_batch = AsyncMock(
return_value=SimpleNamespace(operation_id="op-1", operation_ids=None)
)
seq = list(statuses)
async def _status(**kwargs):
value = seq.pop(0) if len(seq) > 1 else seq[0]
return SimpleNamespace(status=value)
client.operations = MagicMock()
client.operations.get_operation_status = AsyncMock(side_effect=_status)
return client
def test_tracks_async_operation_id_from_retain(self, provider):
provider._client.aretain_batch = AsyncMock(
return_value=SimpleNamespace(operation_id="op-async-1", operation_ids=None)
)
provider.sync_turn("hello", "world")
provider._retain_queue.join()
assert "op-async-1" in provider._pending_retain_ops
def test_tracks_multiple_operation_ids(self, provider):
provider._client.aretain_batch = AsyncMock(
return_value=SimpleNamespace(
operation_id=None, operation_ids=["op-a", "op-b"]
)
)
provider.sync_turn("hello", "world")
provider._retain_queue.join()
assert {"op-a", "op-b"} <= provider._pending_retain_ops
def test_sync_retain_tracks_no_ops(self, provider_with_config):
p = provider_with_config(retain_async=False)
p._client = _make_mock_client()
p._client.aretain_batch = AsyncMock(
return_value=SimpleNamespace(operation_id="op-x", operation_ids=None)
)
p.sync_turn("hello", "world")
p._retain_queue.join()
# retain_async=False → no server-side op to wait on.
assert p._pending_retain_ops == set()
def test_prefetch_waits_for_server_completion_before_recall(self, provider):
"""Recall must not run until the tracked async op reports completed."""
order = []
async def _recall(**kwargs):
order.append("recall")
return SimpleNamespace(results=[SimpleNamespace(text="m")])
provider._client = self._client_with_ops(["pending", "pending", "completed"])
provider._client.arecall = AsyncMock(side_effect=_recall)
provider.sync_turn("hello", "world")
provider._retain_queue.join()
assert "op-1" in provider._pending_retain_ops
provider.queue_prefetch("next turn query")
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=5.0)
# Recall ran, the op was polled to completion, and the pending set
# was cleared (so a later prefetch won't re-poll it).
assert order == ["recall"]
assert provider._client.operations.get_operation_status.await_count >= 3
assert provider._pending_retain_ops == set()
def test_prefetch_proceeds_after_server_wait_timeout(self, provider_with_config):
"""A wedged/never-completing async op must not hang prefetch forever;
it recalls anyway once the drain budget is exhausted."""
p = provider_with_config(prefetch_retain_drain_timeout=0.3)
order = []
async def _recall(**kwargs):
order.append("recall")
return SimpleNamespace(results=[SimpleNamespace(text="m")])
p._client = self._client_with_ops(["pending"]) # never completes
p._client.arecall = AsyncMock(side_effect=_recall)
p.sync_turn("hello", "world")
p._retain_queue.join()
start = time.monotonic()
p.queue_prefetch("next turn query")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
elapsed = time.monotonic() - start
assert order == ["recall"], "prefetch should recall after the timeout"
assert elapsed < 3.0, "prefetch must not block well past the drain budget"
def test_timed_out_ops_are_dropped_not_repolled(self, provider_with_config):
"""Ops unresolved at deadline must be EVICTED so a permanently failing
status endpoint can't make every later prefetch re-burn the full
timeout on a growing pending set (unbounded session-wide degradation
+ reply-path join penalty)."""
p = provider_with_config(prefetch_retain_drain_timeout=0.3)
p._client = self._client_with_ops(["pending"]) # never completes
p._client.arecall = AsyncMock(
return_value=SimpleNamespace(results=[SimpleNamespace(text="m")])
)
p.sync_turn("hello", "world")
p._retain_queue.join()
assert p._pending_retain_ops, "op should be tracked before the wait"
# First prefetch burns the budget and must DROP the wedged op.
p.queue_prefetch("q1")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
assert p._pending_retain_ops == set(), (
"unresolved ops must be evicted at deadline, not retained"
)
# A later prefetch with nothing pending must be near-instant.
start = time.monotonic()
p.queue_prefetch("q2")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
assert time.monotonic() - start < 0.25, (
"second prefetch re-polled dropped ops — eviction regressed"
)
def test_operation_notfound_treated_as_complete(self, provider):
"""A NotFound (completed+evicted) op is treated as done, not pending."""
from hindsight_client_api.exceptions import NotFoundException
client = _make_mock_client()
client.operations = MagicMock()
client.operations.get_operation_status = AsyncMock(
side_effect=NotFoundException(status=404, reason="gone")
)
provider._client = client
assert provider._is_retain_op_complete("bank", "op-gone") is True
def test_transient_status_error_keeps_waiting(self, provider):
"""A transient status-check error means 'unknown', so keep waiting."""
client = _make_mock_client()
client.operations = MagicMock()
client.operations.get_operation_status = AsyncMock(
side_effect=RuntimeError("temporary blip")
)
provider._client = client
assert provider._is_retain_op_complete("bank", "op-1") is False
# ---------------------------------------------------------------------------
# recall_status (deterministic recall indicator) tests
# ---------------------------------------------------------------------------
class TestRecallStatus:
def test_none_before_any_prefetch(self, provider):
# Nothing recalled yet → no indicator.
assert provider.recall_status() is None
def test_reports_count_after_recall(self, provider):
# Mock client returns 2 memories; prefetch consumes the block.
provider.queue_prefetch("test")
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=5.0)
provider.prefetch("test")
status = provider.recall_status()
assert status is not None
assert status.provider_label == "Hindsight"
assert status.count == 2
def test_reports_count_in_recall_sync_mode(self, provider_with_config):
# recall_sync path does a live recall inside prefetch() (no background
# prime) — the indicator must still report the count for that turn.
p = provider_with_config(recall_sync=True)
assert p.prefetch("test") # live recall returns the 2 mock memories
status = p.recall_status()
assert status is not None
assert status.count == 2
def test_none_when_recall_returned_nothing(self, provider):
provider._client.arecall = AsyncMock(
return_value=SimpleNamespace(results=[])
)
provider.queue_prefetch("test")
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=5.0)
assert provider.prefetch("test") == ""
assert provider.recall_status() is None
def test_stale_count_cleared_on_empty_turn(self, provider):
# First turn recalls 2 memories.
provider.queue_prefetch("test")
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=5.0)
provider.prefetch("test")
assert provider.recall_status().count == 2
# Next turn recalls nothing — the prior count must not linger.
provider._client.arecall = AsyncMock(
return_value=SimpleNamespace(results=[])
)
provider.queue_prefetch("test2")
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=5.0)
provider.prefetch("test2")
assert provider.recall_status() is None
def test_suppressed_when_indicator_off(self, provider_with_config):
p = provider_with_config(recall_indicator=False)
p.queue_prefetch("test")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
p.prefetch("test")
# Memory was injected, but the indicator is turned off.
assert p._last_recall_returned is True
assert p.recall_status() is None
def test_reflect_mode_reports_generic_count(self, provider_with_config):
p = provider_with_config(recall_prefetch_method="reflect")
p.queue_prefetch("test")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)
p.prefetch("test")
status = p.recall_status()
assert status is not None
# Reflect synthesizes across memories → no discrete count (0).
assert status.count == 0
# ---------------------------------------------------------------------------
# sync_turn tests
# ---------------------------------------------------------------------------
class TestSyncTurn:
def test_sync_turn_retains_metadata_rich_turn(self, provider_with_config, monkeypatch):
event_time = datetime(2026, 8, 10, 11, 9, tzinfo=ZoneInfo("Asia/Shanghai"))
monkeypatch.setattr("plugins.memory.hindsight._hermes_now", lambda: event_time)
p = provider_with_config(
retain_tags=["conv", "session1"],
retain_source="hermes",
retain_user_prefix="User (fakeusername)",
retain_assistant_prefix="Assistant (fakeassistantname)",
)
p.initialize(
session_id="session-1",
platform="discord",
user_id="fakeusername-123",
user_name="fakeusername",
chat_id="1485316232612941897",
chat_name="fakeassistantname-forums",
chat_type="thread",
thread_id="1491249007475949698",
agent_identity="fakeassistantname",
)
p._client = _make_mock_client()
p.sync_turn("hello", "hi there")
p._retain_queue.join()
p._client.aretain_batch.assert_called_once()
call_kwargs = p._client.aretain_batch.call_args.kwargs
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["document_id"].startswith("session-1-")
assert call_kwargs["retain_async"] is True
assert len(call_kwargs["items"]) == 1
item = call_kwargs["items"][0]
assert item["context"] == "conversation between Hermes Agent and the User"
assert item["tags"] == ["conv", "session1", "session:session-1"]
content = json.loads(item["content"])
assert len(content) == 1
assert content[0][0]["role"] == "user"
assert content[0][0]["content"] == "User (fakeusername): hello"
assert content[0][1]["role"] == "assistant"
assert content[0][1]["content"] == "Assistant (fakeassistantname): hi there"
assert item["metadata"]["source"] == "hermes"
assert item["metadata"]["session_id"] == "session-1"
assert item["metadata"]["platform"] == "discord"
assert item["metadata"]["user_id"] == "fakeusername-123"
assert item["metadata"]["user_name"] == "fakeusername"
assert item["metadata"]["chat_id"] == "1485316232612941897"
assert item["metadata"]["chat_name"] == "fakeassistantname-forums"
assert item["metadata"]["chat_type"] == "thread"
assert item["metadata"]["thread_id"] == "1491249007475949698"
assert item["metadata"]["agent_identity"] == "fakeassistantname"
assert item["metadata"]["turn_index"] == "1"
assert item["metadata"]["message_count"] == "2"
assert content[0][0]["timestamp"] == event_time.isoformat(timespec="seconds")
assert content[0][1]["timestamp"] == event_time.isoformat(timespec="seconds")
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", item["metadata"]["retained_at"])
assert item["timestamp"] == event_time.isoformat(timespec="seconds")
def test_retain_timestamp_normalizes_a_naive_clock(self, provider, monkeypatch):
event_time = datetime(2026, 8, 10, 11, 9)
monkeypatch.setattr("plugins.memory.hindsight._hermes_now", lambda: event_time)
timestamp = provider._build_retain_kwargs("hello")["timestamp"]
parsed = datetime.fromisoformat(timestamp)
assert parsed.tzinfo is not None
assert parsed.utcoffset() is not None
@pytest.mark.asyncio
async def test_retain_timestamp_is_serialized_by_pinned_client(self, provider):
hindsight_client = pytest.importorskip(
"hindsight_client", reason="pinned hindsight-client SDK not installed"
)
Hindsight = hindsight_client.Hindsight
item = provider._build_retain_kwargs("hello")
item.pop("bank_id", None)
item.pop("retain_async", None)
client = Hindsight(base_url="http://localhost:9999", api_key="test-key")
client._memory_api.retain_memories = AsyncMock(return_value=SimpleNamespace(ok=True))
try:
await client.aretain_batch(bank_id="test-bank", items=[item])
call = client._memory_api.retain_memories.await_args
assert call is not None
request = call.args[1]
assert request.to_dict()["items"][0]["timestamp"] == item["timestamp"]
finally:
await client.aclose()
def test_resume_creates_new_document(self, tmp_path, monkeypatch):
"""Resuming a session (re-initializing) gets a new document_id
so previously stored content is not overwritten."""
config = {"mode": "cloud", "apiKey": "k", "api_url": "http://x", "bank_id": "b"}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path)
p1 = HindsightMemoryProvider()
p1.initialize(session_id="resumed-session", hermes_home=str(tmp_path), platform="cli")
# Sleep just enough that the microsecond timestamp differs
import time
time.sleep(0.001)
p2 = HindsightMemoryProvider()
p2.initialize(session_id="resumed-session", hermes_home=str(tmp_path), platform="cli")
# Same session, but each process gets its own document_id
assert p1._document_id != p2._document_id
assert p1._document_id.startswith("resumed-session-")
assert p2._document_id.startswith("resumed-session-")
# ---------------------------------------------------------------------------
# retain indicator ("saving to memory") tests
# ---------------------------------------------------------------------------
class TestRetainIndicator:
_SAVING = "👁️ Hindsight — saving to memory…"
def test_emits_saving_on_dispatch(self, provider_with_config):
calls = []
p = provider_with_config(retain_async=False)
p._status_callback = calls.append
p.sync_turn("hello", "hi")
p._retain_queue.join()
assert self._SAVING in calls
def test_suppressed_when_indicator_off(self, provider_with_config):
calls = []
p = provider_with_config(retain_indicator=False, retain_async=False)
p._status_callback = calls.append
p.sync_turn("hello", "hi")
p._retain_queue.join()
assert calls == []
def test_no_emit_when_auto_retain_off(self, provider_with_config):
calls = []
p = provider_with_config(auto_retain=False)
p._status_callback = calls.append
p.sync_turn("hello", "hi") # returns early — nothing dispatched
assert calls == []
def test_no_emit_on_buffered_turn(self, provider_with_config):
# retain_every_n_turns=2: turn 1 buffers (no write, no line),
# turn 2 flushes (one line) — "saving" only fires on a real write.
calls = []
p = provider_with_config(retain_every_n_turns=2, retain_async=False)
p._status_callback = calls.append
p.sync_turn("t1-u", "t1-a")
assert calls == []
p.sync_turn("t2-u", "t2-a")
p._retain_queue.join()
assert calls == [self._SAVING]
def test_no_crash_without_callback(self, provider_with_config):
p = provider_with_config(retain_async=False)
assert p._status_callback is None
p.sync_turn("hello", "hi") # must not raise
p._retain_queue.join()
def test_status_callback_wired_from_initialize(self, tmp_path, monkeypatch):
cb = lambda _m: None
p = _provider_for_mode(tmp_path, monkeypatch, "cloud")
p.initialize(session_id="s", hermes_home=str(tmp_path), status_callback=cb)
assert p._status_callback is cb
# ---------------------------------------------------------------------------
# Shutdown / writer tests
# ---------------------------------------------------------------------------
class TestShutdownRace:
def test_sync_turn_uses_single_writer_thread(self, provider):
"""All retains run through one long-lived writer thread."""
provider.sync_turn("a", "b")
provider._retain_queue.join()
first_writer = provider._writer_thread
assert first_writer is not None
assert first_writer.is_alive()
provider.sync_turn("c", "d")
provider._retain_queue.join()
# Same thread reused — no ad-hoc thread per call.
assert provider._writer_thread is first_writer
assert provider._client.aretain_batch.call_count == 2
def test_shutdown_drains_pending_retains(self, provider):
"""Shutdown must wait for queued retains to complete, not abandon them.
Otherwise the LAST in-flight turn — typically the most important —
is silently lost.
"""
client = provider._client
provider.sync_turn("a", "b")
provider.sync_turn("c", "d")
provider.shutdown()
# Both retains drained before shutdown returned.
assert client.aretain_batch.call_count == 2
assert provider._retain_queue.empty()
# ---------------------------------------------------------------------------
# on_session_switch — flush + prefetch reset behavior
# ---------------------------------------------------------------------------
class TestSessionSwitchBufferFlush:
def test_buffered_turns_flushed_before_clear(self, provider_with_config):
"""retain_every_n_turns > 1 must not silently drop partial buffers
on session switch. Whatever's in _session_turns at switch time
should land in the OLD document under the OLD session id."""
p = provider_with_config(retain_every_n_turns=3, retain_async=False)
old_doc = p._document_id
# Two turns buffered, no retain yet (boundary is at turn 3). The
# writer hasn't been started either — sync_turn's early return
# skips _ensure_writer when no retain is due.
p.sync_turn("turn1-user", "turn1-asst")
p.sync_turn("turn2-user", "turn2-asst")
assert p._sync_thread is None
p._client.aretain_batch.assert_not_called()
# Switch — flush should fire under OLD document_id via the writer queue.
p.on_session_switch("new-sid", parent_session_id="test-session", reset=True)
p._retain_queue.join()
p._client.aretain_batch.assert_called_once()
kw = p._client.aretain_batch.call_args.kwargs
assert kw["document_id"] == old_doc
item = kw["items"][0]
# Both buffered turns must be present in the flushed payload.
content = json.loads(item["content"])
flat = json.dumps(content)
assert "turn1-user" in flat
assert "turn2-user" in flat
# Old session id must appear in lineage tags / metadata.
assert "session:test-session" in item["tags"]
assert item["metadata"]["session_id"] == "test-session"
# And the new session must start with a clean slate.
assert p._session_id == "new-sid"
assert p._session_turns == []
assert p._turn_counter == 0
assert p._document_id != old_doc
assert p._document_id.startswith("new-sid-")
def test_in_flight_prefetch_thread_drained_on_switch(self, provider, monkeypatch):
"""on_session_switch must wait for an in-flight prefetch from the
old session to settle before clearing _prefetch_result, otherwise
the thread can race and re-populate the field after the clear."""
import threading
gate = threading.Event()
finished = threading.Event()
def _slow_prefetch():
gate.wait(timeout=5.0)
with provider._prefetch_lock:
provider._prefetch_result = "old-session recall"
finished.set()
provider._prefetch_thread = threading.Thread(target=_slow_prefetch, daemon=True)
provider._prefetch_thread.start()
# Release the prefetch worker so it writes _prefetch_result, then
# call on_session_switch — it must join the thread before clearing.
gate.set()
provider.on_session_switch("new-sid")
assert finished.is_set(), "switch returned before prefetch thread settled"
assert provider._prefetch_result == ""
def test_flush_serializes_behind_pending_retains_via_writer_queue(
self, provider_with_config
):
"""The flush closure must ride the same _retain_queue sync_turn
uses, so it lands FIFO behind any still-queued old-session
retains rather than racing them on a separate thread.
Regression guard: an earlier draft spawned a raw threading.Thread
for flush, overwriting _sync_thread and racing the writer against
the same document_id.
"""
import threading as _threading
p = provider_with_config(retain_every_n_turns=2, retain_async=False)
# Block the first writer job until we've enqueued the flush
# behind it. This proves ordering — the flush MUST wait.
gate = _threading.Event()
call_order: list[str] = []
def _aretain_batch_tracking(**kw):
idx = kw["items"][0]["metadata"].get("turn_index", "")
call_order.append(str(idx))
if idx == "2":
# First retain blocks until we've enqueued the flush.
gate.wait(timeout=5.0)
p._client.aretain_batch = AsyncMock(side_effect=_aretain_batch_tracking)
# Turn 1+2 → boundary hit → retain enqueued (will block).
p.sync_turn("turn1-user", "turn1-asst")
p.sync_turn("turn2-user", "turn2-asst")
# One more buffered turn so flush has something to land.
p.sync_turn("turn3-user", "turn3-asst")
# Switch while the first retain is still blocked on `gate`.
p.on_session_switch("new-sid", parent_session_id="test-session")
# Release the first retain. Flush must have been enqueued
# BEHIND it, and run second.
gate.set()
p._retain_queue.join()
# The flush carries all buffered turns; sync_turn's retain #2
# carried the batch at boundary time. Two distinct calls.
assert p._client.aretain_batch.call_count == 2
# First call landed while buffer was [t1, t2]; flush landed
# after we added t3. So the second call must be strictly after.
assert call_order[0] == "2"
# Flush retain has turn_index matching the buffered count at
# switch time (3 turns accumulated, _turn_index was set to 3
# by the last sync_turn).
assert call_order[1] == "3"
# ---------------------------------------------------------------------------
# update_mode='append' capability probe + retain dispatch
# ---------------------------------------------------------------------------
class TestUpdateModeAppendCapability:
def _clear_capability_cache(self):
from plugins.memory.hindsight import _append_capability_cache, _append_capability_lock
with _append_capability_lock:
_append_capability_cache.clear()
def test_legacy_api_falls_back_to_per_process_doc_id(self, provider, monkeypatch):
"""API returns no /version (or pre-0.5.0) — sync_turn must use the
per-process unique doc_id and NOT pass update_mode."""
self._clear_capability_cache()
monkeypatch.setattr(
"plugins.memory.hindsight._fetch_hindsight_api_version",
lambda *a, **kw: None,
)
old_doc = provider._document_id
provider.sync_turn("hello", "hi")
provider._retain_queue.join()
kw = provider._client.aretain_batch.call_args.kwargs
assert kw["document_id"] == old_doc
assert kw["document_id"].startswith("test-session-")
item = kw["items"][0]
assert "update_mode" not in item
def test_modern_api_uses_stable_doc_id_with_append(self, provider, monkeypatch):
"""API on >=0.5.0 — retain uses stable session_id and sets update_mode='append'."""
self._clear_capability_cache()
monkeypatch.setattr(
"plugins.memory.hindsight._fetch_hindsight_api_version",
lambda *a, **kw: "0.5.6",
)
provider.sync_turn("hello", "hi")
provider._retain_queue.join()
kw = provider._client.aretain_batch.call_args.kwargs
# Stable: just the session id, no per-process timestamp suffix.
assert kw["document_id"] == "test-session"
item = kw["items"][0]
assert item["update_mode"] == "append"
def test_session_switch_flush_picks_capability_against_old_session(
self, provider_with_config, monkeypatch
):
"""When the API supports append, the flush on /reset must land
in the OLD session's stable document, not a per-process id."""
self._clear_capability_cache()
monkeypatch.setattr(
"plugins.memory.hindsight._fetch_hindsight_api_version",
lambda *a, **kw: "0.5.6",
)
p = provider_with_config(retain_every_n_turns=3, retain_async=False)
p.sync_turn("turn1-user", "turn1-asst")
p.sync_turn("turn2-user", "turn2-asst")
p.on_session_switch("new-sid", parent_session_id="test-session", reset=True)
p._retain_queue.join()
kw = p._client.aretain_batch.call_args.kwargs
# Flush goes to the OLD session's stable doc, not new-sid's.
assert kw["document_id"] == "test-session"
assert kw["items"][0]["update_mode"] == "append"
# ---------------------------------------------------------------------------
# System prompt tests
# ---------------------------------------------------------------------------
class TestSystemPrompt:
def test_hybrid_mode_prompt(self, provider):
block = provider.system_prompt_block()
assert "Hindsight Memory" in block
assert "hindsight_recall" in block
assert "automatically injected" in block
# ---------------------------------------------------------------------------
# Config schema tests
# ---------------------------------------------------------------------------
class TestConfigSchema:
def test_schema_has_all_new_fields(self, provider):
schema = provider.get_config_schema()
keys = {f["key"] for f in schema}
expected_keys = {
"mode", "api_url", "api_key", "llm_provider", "llm_api_key",
"llm_model", "bank_id", "bank_id_template", "bank_mission", "bank_retain_mission",
"recall_budget", "memory_mode", "recall_prefetch_method",
"retain_tags", "retain_source",
"retain_user_prefix", "retain_assistant_prefix",
"recall_tags", "recall_tags_match",
"auto_recall", "auto_retain",
"retain_every_n_turns", "retain_async", "retain_context",
"recall_max_tokens", "recall_max_input_chars",
"recall_prompt_preamble",
}
assert expected_keys.issubset(keys), f"Missing: {expected_keys - keys}"
# ---------------------------------------------------------------------------
# bank_id_template tests
# ---------------------------------------------------------------------------
class TestBankIdTemplate:
def test_sanitize_bank_segment_passthrough(self):
assert _sanitize_bank_segment("hermes") == "hermes"
assert _sanitize_bank_segment("my-agent_1") == "my-agent_1"
def test_resolve_empty_template_uses_fallback(self):
result = _resolve_bank_id_template(
"", fallback="hermes", profile="coder"
)
assert result == "hermes"
def test_resolve_sanitizes_placeholder_values(self):
result = _resolve_bank_id_template(
"user-{user}", fallback="hermes",
profile="", workspace="", platform="",
user="josh@example.com", session="",
)
assert result == "user-josh-example-com"
def test_provider_uses_bank_id_template_from_config(self, tmp_path, monkeypatch):
config = {
"mode": "cloud",
"apiKey": "k",
"api_url": "http://x",
"bank_id": "fallback-bank",
"bank_id_template": "hermes-{profile}",
}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path)
p = HindsightMemoryProvider()
p.initialize(
session_id="s1",
hermes_home=str(tmp_path),
platform="cli",
agent_identity="coder",
agent_workspace="hermes",
)
assert p._bank_id == "hermes-coder"
assert p._bank_id_template == "hermes-{profile}"
# ---------------------------------------------------------------------------
# Availability tests
# ---------------------------------------------------------------------------
class TestAvailability:
def test_available_with_api_key(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"plugins.memory.hindsight.get_hermes_home",
lambda: tmp_path / "nonexistent",
)
monkeypatch.setenv("HINDSIGHT_API_KEY", "test-key")
p = HindsightMemoryProvider()
assert p.is_available()
def test_local_mode_unavailable_when_runtime_import_fails(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"plugins.memory.hindsight.get_hermes_home",
lambda: tmp_path / "nonexistent",
)
monkeypatch.setenv("HINDSIGHT_MODE", "local")
def _raise(_name):
raise RuntimeError(
"NumPy was built with baseline optimizations: (x86_64-v2)"
)
monkeypatch.setattr(
"plugins.memory.hindsight.importlib.import_module",
_raise,
)
p = HindsightMemoryProvider()
assert not p.is_available()
def test_initialize_disables_local_mode_when_runtime_import_fails(self, tmp_path, monkeypatch):
config = {"mode": "local_embedded"}
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(config))
monkeypatch.setattr(
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
)
def _raise(_name):
raise RuntimeError("x86_64-v2 unsupported")
monkeypatch.setattr(
"plugins.memory.hindsight.importlib.import_module",
_raise,
)
p = HindsightMemoryProvider()
p.initialize(session_id="test-session", hermes_home=str(tmp_path), platform="cli")
assert p._mode == "disabled"
class TestSharedEventLoopLifecycle:
"""Regression tests for #11923 — Hindsight leaking aiohttp ClientSession /
TCPConnector objects in long-running gateway processes.
Root cause: the module-global ``_loop`` / ``_loop_thread`` pair is shared
across every HindsightMemoryProvider instance in the process (the plugin
loader builds one provider per AIAgent, and the gateway builds one AIAgent
per concurrent chat session). When a session ended, ``shutdown()`` stopped
the shared loop, which orphaned every *other* live provider's aiohttp
ClientSession on a dead loop. Those sessions were never closed and surfaced
as ``Unclosed client session`` / ``Unclosed connector`` errors.
"""
def test_shutdown_does_not_stop_shared_event_loop(self, provider_with_config):
from plugins.memory import hindsight as hindsight_mod
async def _noop():
return 1
# Prime the shared loop by scheduling a trivial coroutine — mirrors
# the first time any real async call (arecall/aretain/areflect) runs.
assert hindsight_mod._run_sync(_noop()) == 1
loop_before = hindsight_mod._loop
thread_before = hindsight_mod._loop_thread
assert loop_before is not None and loop_before.is_running()
assert thread_before is not None and thread_before.is_alive()
# Build two independent providers (two concurrent chat sessions).
provider_a = provider_with_config()
provider_b = provider_with_config()
# End session A.
provider_a.shutdown()
# Module-global loop/thread must still be the same live objects —
# provider B (and any other sibling provider) is still relying on them.
assert hindsight_mod._loop is loop_before, (
"shutdown() swapped out the shared event loop — sibling providers "
"would have their aiohttp ClientSession orphaned (#11923)"
)
assert hindsight_mod._loop.is_running(), (
"shutdown() stopped the shared event loop — sibling providers' "
"aiohttp sessions would leak (#11923)"
)
assert hindsight_mod._loop_thread is thread_before
assert hindsight_mod._loop_thread.is_alive()
# Provider B can still dispatch async work on the shared loop.
async def _still_working():
return 42
assert hindsight_mod._run_sync(_still_working()) == 42
provider_b.shutdown()
def test_client_aclose_called_on_cloud_mode_shutdown(self, provider):
"""Per-provider session cleanup still runs even though the shared
loop is preserved. Each provider's own aiohttp session is closed
via ``self._client.aclose()``; only the (empty) shared loop survives.
"""
assert provider._client is not None
mock_client = provider._client
provider.shutdown()
mock_client.aclose.assert_called_once()
assert provider._client is None
class TestShutdown:
def test_local_embedded_shutdown_closes_inner_async_client_on_shared_loop(self, provider):
inner_client = _make_mock_client()
embedded = MagicMock()
embedded._client = inner_client
embedded.close = MagicMock()
provider._mode = "local_embedded"
provider._client = embedded
provider.shutdown()
inner_client.aclose.assert_awaited_once()
embedded.close.assert_called_once()
assert embedded._client is None
assert provider._client is None
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path):
"""hindsight/config.json must be written with 0o600 so API key is not world-readable."""
provider = HindsightMemoryProvider()
provider.save_config({"api_key": "hd-test-key"}, str(tmp_path))
config_file = tmp_path / "hindsight" / "config.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
class TestLoadSimpleEnv:
def test_bom_first_key_is_recognized(self, tmp_path):
"""A Notepad-edited .env carries a BOM; the first key must still parse
instead of becoming '\ufeffHINDSIGHT_LLM_API_KEY'."""
env_path = tmp_path / ".env"
env_path.write_bytes("HINDSIGHT_LLM_API_KEY=sk-test\n".encode("utf-8"))
values = _load_simple_env(env_path)
assert values.get("HINDSIGHT_LLM_API_KEY") == "sk-test"
class TestPostSetupEnvEncoding:
def _run_cloud_post_setup(self, tmp_path, monkeypatch):
"""Drive post_setup through the cloud path with piped stdin."""
import io
monkeypatch.setattr("hermes_cli.memory_setup._curses_select",
lambda *a, **kw: 0) # cloud mode
monkeypatch.setattr("hermes_cli.config.save_config", lambda c: None)
# Skip the dependency install (now routed through lazy_deps, NS-605).
import tools.lazy_deps as lazy_deps_mod
monkeypatch.setattr(
lazy_deps_mod, "install_specs",
lambda *a, **kw: lazy_deps_mod.InstallSpecsResult(ok=True),
)
# First line: API key prompt (readline). Second line: API URL (input).
monkeypatch.setattr(sys, "stdin", io.StringIO("sk-new\n\n"))
provider = HindsightMemoryProvider()
provider.post_setup(str(tmp_path), {"memory": {}})
def test_bom_first_key_updated_in_place(self, tmp_path, monkeypatch):
"""The setup writer reads the existing .env BOM-tolerantly, so a
BOM'd first key is matched and rewritten, not duplicated."""
env_path = tmp_path / ".env"
env_path.write_bytes("HINDSIGHT_API_KEY=old\n".encode("utf-8"))
self._run_cloud_post_setup(tmp_path, monkeypatch)
content = env_path.read_text(encoding="utf-8")
assert content.count("HINDSIGHT_API_KEY=") == 1
assert "HINDSIGHT_API_KEY=sk-new" in content
assert "old" not in content
assert "" not in content
class TestClientAutoUpgradeRoutesThroughLazyDeps:
"""The initialize()-time hindsight-client auto-upgrade must go through
lazy_deps.install_specs() (environment-aware, durable-target on sealed
hosted venvs) — never a direct `uv pip install --python sys.executable`
subprocess, which fails with EROFS/EACCES on immutable images (NS-605)."""
def _init_with_outdated_client(self, tmp_path, monkeypatch, outcome):
import importlib.metadata as md
import subprocess as subprocess_mod
import tools.lazy_deps as lazy_deps_mod
config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps({"mode": "cloud"}))
monkeypatch.setattr(
"plugins.memory.hindsight.get_hermes_home", lambda: tmp_path
)
# Simulate an installed-but-outdated client.
monkeypatch.setattr(md, "version", lambda name: "0.0.1")
calls = []
monkeypatch.setattr(
lazy_deps_mod, "install_specs",
lambda specs, **kw: calls.append(tuple(specs)) or outcome,
)
# Regression guard: no direct pip subprocess may run.
def _no_subprocess(*a, **kw): # pragma: no cover - fails loudly
raise AssertionError(f"unexpected subprocess.run during auto-upgrade: {a}")
monkeypatch.setattr(subprocess_mod, "run", _no_subprocess)
provider = HindsightMemoryProvider()
provider.initialize(session_id="s", hermes_home=str(tmp_path), platform="cli")
return calls
def test_upgrade_uses_install_specs_not_subprocess(self, tmp_path, monkeypatch):
from plugins.memory.hindsight import _MIN_CLIENT_VERSION
from tools.lazy_deps import InstallSpecsResult
calls = self._init_with_outdated_client(
tmp_path, monkeypatch, InstallSpecsResult(ok=True)
)
assert calls == [(f"hindsight-client>={_MIN_CLIENT_VERSION}",)]
def test_blocked_upgrade_is_nonfatal_and_surfaces_reason(
self, tmp_path, monkeypatch, caplog
):
import logging
from tools.lazy_deps import InstallSpecsResult
with caplog.at_level(logging.WARNING):
calls = self._init_with_outdated_client(
tmp_path, monkeypatch,
InstallSpecsResult(ok=False, blocked=True,
reason="runtime installs are disabled on this deployment"),
)
assert len(calls) == 1 # attempted exactly once, init still completed
assert any("runtime installs are disabled" in r.getMessage()
for r in caplog.records)
class TestMultiplexBackgroundScope:
"""Under multiplex_profiles get_secret fails closed on an unscoped thread;
the writer / daemon-start threads are spawned from a scoped context and
must carry it along (#92608, #94933)."""
@pytest.fixture()
def scoped_embedded(self, tmp_path, monkeypatch):
from agent.secret_scope import (
build_profile_secret_scope, reset_secret_scope, set_multiplex_active, set_secret_scope,
)
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
created = []
class FakeHindsightEmbedded:
def __init__(self, **kwargs):
created.append(kwargs["llm_api_key"])
self._manager = SimpleNamespace(is_running=lambda profile: False, stop=lambda profile: None)
self._ensure_started = lambda: None
dem = SimpleNamespace(console=None)
monkeypatch.setitem(sys.modules, "hindsight", SimpleNamespace(HindsightEmbedded=FakeHindsightEmbedded))
monkeypatch.setitem(sys.modules, "hindsight_embed", SimpleNamespace(daemon_embed_manager=dem))
monkeypatch.setitem(sys.modules, "hindsight_embed.daemon_embed_manager", dem)
monkeypatch.setattr("plugins.memory.hindsight._check_local_runtime", lambda: (True, ""))
home = tmp_path / "profiles" / "p1"
(home / "hindsight").mkdir(parents=True)
(home / ".env").write_text("HINDSIGHT_LLM_API_KEY=p1-secret\n")
(home / "hindsight" / "config.json").write_text(json.dumps(
{"mode": "local_embedded", "llm_provider": "openai", "llm_model": "m", "memory_mode": "hybrid"}
))
# Enter the profile scope the way gateway _profile_runtime_scope does.
set_multiplex_active(True)
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: home)
home_tok = set_hermes_home_override(str(home))
scope_tok = set_secret_scope(build_profile_secret_scope(home))
yield created, home
set_multiplex_active(False)
reset_secret_scope(scope_tok)
reset_hermes_home_override(home_tok)
def test_writer_thread_resolves_profile_secret(self, scoped_embedded):
created, home = scoped_embedded
p = HindsightMemoryProvider()
p._mode = "local_embedded"
p._config = {"profile": "hermes", "llm_provider": "openai", "llm_model": "m"}
p._ensure_writer()
p._retain_queue.put(p._get_client) # real body: get_secret(HINDSIGHT_LLM_API_KEY)
p._retain_queue.put(_WRITER_SENTINEL)
p._writer_thread.join(timeout=5)
assert created == ["p1-secret"]
def test_daemon_start_thread_resolves_profile_secret(self, scoped_embedded):
created, home = scoped_embedded
p = HindsightMemoryProvider()
p.initialize(session_id="s1", hermes_home=str(home), platform="cli")
for t in threading.enumerate():
if t.name == "hindsight-daemon-start":
t.join(timeout=5)
assert created == ["p1-secret"]
assert "Daemon started successfully" in (home / "logs" / "hindsight-embed.log").read_text()