Download tests/honcho_plugin/test_session.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 66.7 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/honcho_plugin/test_session.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/tests/honcho_plugin/test_session.py
-
curl -L -o test_session.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/honcho_plugin/test_session.py
66.7 kB
| """Tests for plugins/memory/honcho/session.py — HonchoSession and helpers.""" | |
| import json | |
| import os | |
| import sys | |
| import threading | |
| import time | |
| from datetime import datetime | |
| from types import SimpleNamespace | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from plugins.memory.honcho.session import ( | |
| HonchoSession, | |
| HonchoSessionManager, | |
| ) | |
| from plugins.memory.honcho import HonchoMemoryProvider | |
| # --------------------------------------------------------------------------- | |
| # HonchoSession dataclass | |
| # --------------------------------------------------------------------------- | |
| class TestHonchoSession: | |
| def _make_session(self): | |
| return HonchoSession( | |
| key="telegram:12345", | |
| user_peer_id="user-telegram-12345", | |
| assistant_peer_id="hermes-assistant", | |
| honcho_session_id="telegram-12345", | |
| ) | |
| def test_initial_state(self): | |
| session = self._make_session() | |
| assert session.key == "telegram:12345" | |
| assert session.messages == [] | |
| assert isinstance(session.created_at, datetime) | |
| assert isinstance(session.updated_at, datetime) | |
| def test_add_message(self): | |
| session = self._make_session() | |
| session.add_message("user", "Hello!") | |
| assert len(session.messages) == 1 | |
| assert session.messages[0]["role"] == "user" | |
| assert session.messages[0]["content"] == "Hello!" | |
| assert "timestamp" in session.messages[0] | |
| # --------------------------------------------------------------------------- | |
| # HonchoSessionManager._sanitize_id | |
| # --------------------------------------------------------------------------- | |
| class TestSanitizeId: | |
| def test_clean_id_unchanged(self): | |
| mgr = HonchoSessionManager() | |
| assert mgr._sanitize_id("telegram-12345") == "telegram-12345" | |
| def test_special_chars_replaced(self): | |
| mgr = HonchoSessionManager() | |
| result = mgr._sanitize_id("user@chat#room!") | |
| assert "@" not in result | |
| assert "#" not in result | |
| assert "!" not in result | |
| class TestPeerLookupHelpers: | |
| def _make_cached_manager(self): | |
| mgr = HonchoSessionManager() | |
| session = HonchoSession( | |
| key="telegram:123", | |
| user_peer_id="robert", | |
| assistant_peer_id="hermes", | |
| honcho_session_id="telegram-123", | |
| ) | |
| mgr._cache[session.key] = session | |
| return mgr, session | |
| def test_set_peer_card_uses_observer_target_in_ai_observe_others_mode(self): | |
| # Writes must go to the same observer-target slot that reads check, | |
| # so that a subsequent honcho_profile read returns what was written. | |
| mgr, session = self._make_cached_manager() | |
| assistant_peer = MagicMock() | |
| assistant_peer.set_card.return_value = ["Role: user"] | |
| mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) | |
| result = mgr.set_peer_card(session.key, ["Role: user"]) | |
| assert result == ["Role: user"] | |
| assistant_peer.set_card.assert_called_once_with(["Role: user"], target=session.user_peer_id) | |
| def test_search_context_uses_peer_perspective_message_search(self): | |
| """Search spans the target peer's sessions instead of its representation.""" | |
| mgr, session = self._make_cached_manager() | |
| honcho_client = MagicMock() | |
| honcho_client.search.return_value = [ | |
| SimpleNamespace(content="Robert runs neuralancer", peer_id="hermes", session_id="s-old", id="m1"), | |
| SimpleNamespace(content="I founded neuralancer in 2019", peer_id="robert", session_id="s-old", id="m2"), | |
| ] | |
| with patch.object(HonchoSessionManager, "honcho", new_callable=lambda: property(lambda s: honcho_client)): | |
| result = mgr.search_context(session.key, "neuralancer") | |
| # Returns the actual message content, ranked. | |
| assert "Robert runs neuralancer" in result | |
| assert "neuralancer in 2019" in result | |
| # Scoped to the target (user) peer's sessions, all authors. | |
| honcho_client.search.assert_called_once() | |
| _args, kwargs = honcho_client.search.call_args | |
| assert kwargs["filters"] == {"peer_perspective": session.user_peer_id} | |
| # Assistant-authored messages are labeled so the model can tell | |
| # user-stated facts from assistant-derived ones. | |
| assert "[assistant" in result | |
| def test_create_conclusion_defaults_to_user_target(self): | |
| mgr, session = self._make_cached_manager() | |
| assistant_peer = MagicMock() | |
| scope = MagicMock() | |
| assistant_peer.conclusions_of.return_value = scope | |
| mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) | |
| ok = mgr.create_conclusion(session.key, "User prefers dark mode") | |
| assert ok is True | |
| assistant_peer.conclusions_of.assert_called_once_with(session.user_peer_id) | |
| scope.create.assert_called_once_with([{ | |
| "content": "User prefers dark mode", | |
| "session_id": session.honcho_session_id, | |
| }]) | |
| class TestConcludeToolDispatch: | |
| def test_conclude_schema_has_no_anyof(self): | |
| """anyOf/oneOf/allOf breaks Anthropic and Fireworks APIs — schema must be plain object.""" | |
| from plugins.memory.honcho.tool_schemas import CONCLUDE_SCHEMA | |
| params = CONCLUDE_SCHEMA["parameters"] | |
| assert params["type"] == "object" | |
| assert "conclusion" in params["properties"] | |
| assert "delete_id" in params["properties"] | |
| assert "list" in params["properties"] | |
| assert "query" in params["properties"] | |
| assert "anyOf" not in params | |
| assert "oneOf" not in params | |
| assert "allOf" not in params | |
| def test_honcho_conclude_defaults_to_user_peer(self): | |
| provider = HonchoMemoryProvider() | |
| provider._session_initialized = True | |
| provider._session_key = "telegram:123" | |
| provider._manager = MagicMock() | |
| provider._manager.create_conclusion.return_value = True | |
| result = provider.handle_tool_call( | |
| "honcho_conclude", | |
| {"conclusion": "User prefers dark mode"}, | |
| ) | |
| assert "Conclusion saved for user" in result | |
| provider._manager.create_conclusion.assert_called_once_with( | |
| "telegram:123", | |
| "User prefers dark mode", | |
| peer="user", | |
| ) | |
| def test_sync_turn_strips_leaked_memory_context_before_honcho_ingest(self): | |
| provider = HonchoMemoryProvider() | |
| provider._session_key = "telegram:123" | |
| provider._manager = MagicMock() | |
| provider._cron_skipped = False | |
| provider._config = SimpleNamespace(message_max_chars=25000) | |
| session = MagicMock() | |
| provider._manager.get_or_create.return_value = session | |
| provider.sync_turn( | |
| ( | |
| "hello\n\n" | |
| "<memory-context>\n" | |
| "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n" | |
| "## Honcho Context\n" | |
| "stale memory\n" | |
| "</memory-context>" | |
| ), | |
| ( | |
| "<memory-context>\n" | |
| "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n" | |
| "## Honcho Context\n" | |
| "stale memory\n" | |
| "</memory-context>\n\n" | |
| "Visible answer" | |
| ), | |
| ) | |
| provider._sync_thread.join(timeout=1.0) | |
| assert session.add_message.call_args_list[0].args == ("user", "hello") | |
| assert session.add_message.call_args_list[1].args == ("assistant", "Visible answer") | |
| # --------------------------------------------------------------------------- | |
| # Message chunking | |
| # --------------------------------------------------------------------------- | |
| # --------------------------------------------------------------------------- | |
| # Provider init behavior: lazy vs eager in tools mode | |
| # --------------------------------------------------------------------------- | |
| class TestToolsModeInitBehavior: | |
| """Verify initOnSessionStart controls session init timing in tools mode.""" | |
| def _make_provider_with_config(self, recall_mode="tools", init_on_session_start=False, | |
| peer_name=None, user_id=None, user_id_alt=None): | |
| """Create a HonchoMemoryProvider with mocked config and dependencies.""" | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| cfg = HonchoClientConfig( | |
| api_key="test-key", | |
| enabled=True, | |
| recall_mode=recall_mode, | |
| init_on_session_start=init_on_session_start, | |
| peer_name=peer_name, | |
| ) | |
| provider = HonchoMemoryProvider() | |
| # Patch the config loading and session init to avoid real Honcho calls | |
| from unittest.mock import patch, MagicMock | |
| mock_manager = MagicMock() | |
| mock_session = MagicMock() | |
| mock_session.messages = [] | |
| mock_manager.get_or_create.return_value = mock_session | |
| init_kwargs = {} | |
| if user_id: | |
| init_kwargs["user_id"] = user_id | |
| if user_id_alt: | |
| init_kwargs["user_id_alt"] = user_id_alt | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager) as mock_manager_cls, \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-session-001", **init_kwargs) | |
| return provider, cfg, mock_manager_cls | |
| def test_tools_lazy_default(self): | |
| """tools + initOnSessionStart=false → session NOT initialized after initialize().""" | |
| provider, _, _ = self._make_provider_with_config( | |
| recall_mode="tools", init_on_session_start=False, | |
| ) | |
| assert provider._session_initialized is False | |
| assert provider._manager is None | |
| assert provider._lazy_init_kwargs is not None | |
| def test_explicit_peer_name_not_overridden_by_user_id(self): | |
| """Explicit peerName in config must not be replaced by gateway user_id.""" | |
| _, cfg, _ = self._make_provider_with_config( | |
| recall_mode="tools", init_on_session_start=True, | |
| peer_name="Kathie", user_id="8439114563", | |
| ) | |
| assert cfg.peer_name == "Kathie" | |
| def test_user_id_alt_is_passed_to_session_manager(self): | |
| """Gateway alternate user IDs are available for Honcho alias matching.""" | |
| _, _, mock_manager_cls = self._make_provider_with_config( | |
| recall_mode="tools", init_on_session_start=True, | |
| peer_name=None, user_id="open-id", user_id_alt="union-id", | |
| ) | |
| assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "open-id" | |
| assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name_alt"] == "union-id" | |
| class TestPerSessionMigrateGuard: | |
| """Verify migrate_memory_files is skipped under per-session strategy. | |
| per-session creates a fresh Honcho session every Hermes run. Uploading | |
| MEMORY.md/USER.md/SOUL.md to each short-lived session floods the backend | |
| with duplicate content. The guard was added to prevent orphan sessions | |
| containing only <prior_memory_file> wrappers. | |
| """ | |
| def _make_provider_with_strategy(self, strategy, init_on_session_start=True): | |
| """Create a HonchoMemoryProvider and track migrate_memory_files calls.""" | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| from unittest.mock import patch, MagicMock | |
| cfg = HonchoClientConfig( | |
| api_key="test-key", | |
| enabled=True, | |
| recall_mode="tools", | |
| init_on_session_start=init_on_session_start, | |
| session_strategy=strategy, | |
| ) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_session = MagicMock() | |
| mock_session.messages = [] # empty = new session → triggers migration path | |
| mock_manager.get_or_create.return_value = mock_session | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-session-001") | |
| return provider, mock_manager | |
| def test_migrate_skipped_for_per_session(self): | |
| """per-session strategy must NOT call migrate_memory_files.""" | |
| _, mock_manager = self._make_provider_with_strategy("per-session") | |
| mock_manager.migrate_memory_files.assert_not_called() | |
| class TestChunkMessage: | |
| def test_short_message_single_chunk(self): | |
| result = HonchoMemoryProvider._chunk_message("hello world", 100) | |
| assert result == ["hello world"] | |
| def test_splits_at_paragraph_boundary(self): | |
| msg = "first paragraph.\n\nsecond paragraph." | |
| # limit=30: total is 35, forces split; second chunk with prefix is 29, fits | |
| result = HonchoMemoryProvider._chunk_message(msg, 30) | |
| assert len(result) == 2 | |
| assert result[0] == "first paragraph." | |
| assert result[1] == "[continued] second paragraph." | |
| def test_continuation_prefix(self): | |
| msg = "a" * 200 | |
| result = HonchoMemoryProvider._chunk_message(msg, 50) | |
| assert len(result) >= 2 | |
| assert not result[0].startswith("[continued]") | |
| for chunk in result[1:]: | |
| assert chunk.startswith("[continued] ") | |
| # --------------------------------------------------------------------------- | |
| # Context token budget enforcement | |
| # --------------------------------------------------------------------------- | |
| class TestTruncateToBudget: | |
| def test_truncates_oversized_context(self): | |
| """Text exceeding context_tokens budget is truncated at a word boundary.""" | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| provider = HonchoMemoryProvider() | |
| provider._config = HonchoClientConfig(context_tokens=10) | |
| long_text = "word " * 200 # ~1000 chars, well over 10*4=40 char budget | |
| result = provider._truncate_to_budget(long_text) | |
| assert len(result) <= 50 # budget_chars + ellipsis + word boundary slack | |
| assert result.endswith(" …") | |
| def test_context_tokens_cap_bounds_prefetch(self): | |
| """With an explicit token budget, oversized prefetch is bounded.""" | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| provider = HonchoMemoryProvider() | |
| provider._config = HonchoClientConfig(context_tokens=1200) | |
| # Simulate a massive representation (10k chars) | |
| huge_text = "x" * 10000 | |
| result = provider._truncate_to_budget(huge_text) | |
| # 1200 tokens * 4 chars = 4800 chars + " …" | |
| assert len(result) <= 4805 | |
| # --------------------------------------------------------------------------- | |
| # Dialectic input guard | |
| # --------------------------------------------------------------------------- | |
| class TestDialecticInputGuard: | |
| def test_long_query_truncated(self): | |
| """Queries exceeding dialectic_max_input_chars are truncated.""" | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| cfg = HonchoClientConfig(dialectic_max_input_chars=100) | |
| mgr = HonchoSessionManager(config=cfg) | |
| mgr._dialectic_max_input_chars = 100 | |
| # Create a cached session so dialectic_query doesn't bail early | |
| session = HonchoSession( | |
| key="test", user_peer_id="u", assistant_peer_id="a", | |
| honcho_session_id="s", | |
| ) | |
| mgr._cache["test"] = session | |
| # Mock the peer to capture the query | |
| mock_peer = MagicMock() | |
| mock_peer.chat.return_value = "answer" | |
| mgr._get_or_create_peer = MagicMock(return_value=mock_peer) | |
| long_query = "word " * 100 # 500 chars, exceeds 100 limit | |
| mgr.dialectic_query("test", long_query) | |
| # The query passed to chat() should be truncated | |
| actual_query = mock_peer.chat.call_args[0][0] | |
| assert len(actual_query) <= 100 | |
| class TestDialecticInjectionCap: | |
| """dialecticMaxChars applies to injection, not explicit reasoning calls.""" | |
| def _manager_with_long_answer(self, answer): | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| cfg = HonchoClientConfig(dialectic_max_chars=50) | |
| mgr = HonchoSessionManager(config=cfg) | |
| mgr._dialectic_max_chars = 50 | |
| session = HonchoSession( | |
| key="test", user_peer_id="u", assistant_peer_id="a", | |
| honcho_session_id="s", | |
| ) | |
| mgr._cache["test"] = session | |
| mock_peer = MagicMock() | |
| mock_peer.chat.return_value = answer | |
| mgr._get_or_create_peer = MagicMock(return_value=mock_peer) | |
| return mgr | |
| def test_injection_path_truncates(self): | |
| """Default (auto-injection) path clips to dialecticMaxChars with an ellipsis.""" | |
| answer = "fact " * 100 # 500 chars, well over the 50-char cap | |
| mgr = self._manager_with_long_answer(answer) | |
| result = mgr.dialectic_query("test", "summarize") | |
| assert len(result) <= 60 # cap + word-boundary slack + ellipsis | |
| assert result.endswith(" …") | |
| def test_tool_path_returns_full_answer(self): | |
| """Explicit tool call (apply_injection_cap=False) returns the full answer.""" | |
| answer = "fact " * 100 # 500 chars, well over the 50-char cap | |
| mgr = self._manager_with_long_answer(answer) | |
| result = mgr.dialectic_query("test", "summarize", apply_injection_cap=False) | |
| assert result == answer | |
| assert not result.endswith(" …") | |
| # --------------------------------------------------------------------------- | |
| def _settle_prewarm(provider): | |
| """Wait for the session-start prewarm dialectic thread, then return the | |
| provider to a clean 'nothing fired yet' state so cadence/first-turn/ | |
| trivial-prompt tests can assert from a known baseline.""" | |
| if provider._prefetch_thread: | |
| provider._prefetch_thread.join(timeout=3.0) | |
| with provider._prefetch_lock: | |
| provider._prefetch_result = "" | |
| provider._prefetch_result_fired_at = -999 | |
| provider._prefetch_thread = None | |
| provider._prefetch_thread_started_at = 0.0 | |
| provider._last_dialectic_turn = -999 | |
| provider._dialectic_empty_streak = 0 | |
| if getattr(provider, "_manager", None) is not None: | |
| try: | |
| provider._manager.dialectic_query.reset_mock() | |
| provider._manager.prefetch_context.reset_mock() | |
| except AttributeError: | |
| pass | |
| class TestDialecticCadenceDefaults: | |
| """Regression tests for dialectic_cadence default value.""" | |
| def _make_provider(cfg_extra=None): | |
| """Create a HonchoMemoryProvider with mocked dependencies.""" | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid") | |
| if cfg_extra: | |
| defaults.update(cfg_extra) | |
| cfg = HonchoClientConfig(**defaults) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_session = MagicMock() | |
| mock_session.messages = [] | |
| mock_manager.get_or_create.return_value = mock_session | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-session-001") | |
| _settle_prewarm(provider) | |
| return provider | |
| def test_unset_falls_back_to_1(self): | |
| """Unset dialecticCadence falls back to 1 (every turn) for backwards | |
| compatibility with existing configs that predate the setting. The | |
| setup wizard writes 2 explicitly on new configs.""" | |
| provider = self._make_provider() | |
| assert provider._dialectic_cadence == 1 | |
| def test_first_turn_only_injection_disables_base_refresh(self): | |
| provider = self._make_provider( | |
| cfg_extra={"injection_frequency": "first-turn", "context_cadence": 1} | |
| ) | |
| provider._turn_count = 2 | |
| provider._last_dialectic_turn = 2 | |
| provider._manager.prefetch_context.reset_mock() | |
| provider.queue_prefetch("follow-up question") | |
| provider._manager.prefetch_context.assert_not_called() | |
| class TestBaseContextSummary: | |
| """Base context injection should include session summary when available.""" | |
| def test_format_includes_summary(self): | |
| """Session summary should appear first in the formatted context.""" | |
| provider = HonchoMemoryProvider() | |
| ctx = { | |
| "summary": "Testing Honcho tools and dialectic depth.", | |
| "representation": "Eri is a developer.", | |
| "card": "Name: Eri Barrett", | |
| } | |
| formatted = provider._format_first_turn_context(ctx) | |
| assert "## Session Summary" in formatted | |
| assert formatted.index("Session Summary") < formatted.index("User Representation") | |
| def test_timed_out_first_turn_context_surfaces_next_turn(self): | |
| import threading | |
| import time | |
| ready = threading.Event() | |
| cached = {} | |
| manager = MagicMock() | |
| def get_context(*args, **kwargs): | |
| ready.wait(timeout=1) | |
| return {"representation": "late user context", "card": ""} | |
| manager.get_prefetch_context.side_effect = get_context | |
| manager.set_context_result.side_effect = ( | |
| lambda session_key, result: cached.__setitem__(session_key, result) | |
| ) | |
| manager.pop_context_result.side_effect = ( | |
| lambda session_key: cached.pop(session_key, {}) | |
| ) | |
| provider = HonchoMemoryProvider() | |
| provider._manager = manager | |
| provider._config = SimpleNamespace(timeout=0.01, context_tokens=0) | |
| provider._session_key = "test" | |
| provider._session_initialized = True | |
| provider._recall_mode = "context" | |
| provider._turn_count = 1 | |
| provider._last_dialectic_turn = 0 | |
| assert provider.prefetch("first question") == "" | |
| ready.set() | |
| deadline = time.monotonic() + 1 | |
| while "test" not in cached and time.monotonic() < deadline: | |
| time.sleep(0.01) | |
| provider._turn_count = 2 | |
| assert "late user context" in provider.prefetch("follow-up question") | |
| def test_later_turn_does_not_wait_for_in_flight_dialectic(self): | |
| import threading | |
| import time | |
| release = threading.Event() | |
| provider = HonchoMemoryProvider() | |
| provider._manager = MagicMock() | |
| provider._manager.pop_context_result.return_value = {} | |
| provider._config = SimpleNamespace(timeout=10.0, context_tokens=0) | |
| provider._session_key = "test" | |
| provider._session_initialized = True | |
| provider._base_context_cache = "" | |
| provider._turn_count = 2 | |
| provider._last_dialectic_turn = 1 | |
| provider._prefetch_thread = threading.Thread( | |
| target=lambda: release.wait(timeout=5), daemon=True | |
| ) | |
| provider._prefetch_thread.start() | |
| provider._prefetch_thread_started_at = time.monotonic() | |
| try: | |
| started = time.perf_counter() | |
| assert provider.prefetch("follow-up question") == "" | |
| assert time.perf_counter() - started < 0.2 | |
| finally: | |
| release.set() | |
| provider._prefetch_thread.join(timeout=1) | |
| class TestDialecticDepth: | |
| """Tests for the dialecticDepth multi-pass system.""" | |
| def _make_provider(cfg_extra=None): | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid") | |
| if cfg_extra: | |
| defaults.update(cfg_extra) | |
| cfg = HonchoClientConfig(**defaults) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_session = MagicMock() | |
| mock_session.messages = [] | |
| mock_manager.get_or_create.return_value = mock_session | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-session-001") | |
| _settle_prewarm(provider) | |
| return provider | |
| def test_default_depth_is_1(self): | |
| """Default dialecticDepth should be 1 — single .chat() call.""" | |
| provider = self._make_provider() | |
| assert provider._dialectic_depth == 1 | |
| def test_depth_clamped_to_3(self): | |
| """dialecticDepth > 3 gets clamped to 3.""" | |
| provider = self._make_provider(cfg_extra={"dialectic_depth": 7}) | |
| assert provider._dialectic_depth == 3 | |
| def test_resolve_pass_level_uses_depth_levels(self): | |
| """Per-pass levels from dialecticDepthLevels override proportional.""" | |
| provider = self._make_provider(cfg_extra={ | |
| "dialectic_depth": 2, | |
| "dialectic_depth_levels": ["minimal", "high"], | |
| }) | |
| assert provider._resolve_pass_level(0) == "minimal" | |
| assert provider._resolve_pass_level(1) == "high" | |
| def test_cold_start_prompt(self): | |
| """Cold start (no base context) uses general user query.""" | |
| provider = self._make_provider() | |
| prompt = provider._build_dialectic_prompt(0, [], is_cold=True) | |
| assert "preferences" in prompt.lower() | |
| assert "session" not in prompt.lower() | |
| def test_signal_sufficient_short_response(self): | |
| """Short responses are not sufficient signal.""" | |
| assert not HonchoMemoryProvider._signal_sufficient("ok") | |
| assert not HonchoMemoryProvider._signal_sufficient("") | |
| assert not HonchoMemoryProvider._signal_sufficient(None) | |
| def test_run_dialectic_depth_single_pass(self): | |
| """Depth 1 makes exactly one .chat() call.""" | |
| from unittest.mock import MagicMock | |
| provider = self._make_provider(cfg_extra={"dialectic_depth": 1}) | |
| provider._manager = MagicMock() | |
| provider._manager.dialectic_query.return_value = "user prefers zero-fluff" | |
| provider._session_key = "test" | |
| provider._base_context_cache = None # cold start | |
| result = provider._run_dialectic_depth("hello") | |
| assert result == "user prefers zero-fluff" | |
| assert provider._manager.dialectic_query.call_count == 1 | |
| # --------------------------------------------------------------------------- | |
| # Trivial-prompt heuristic + dialectic cadence silent-failure guards | |
| # --------------------------------------------------------------------------- | |
| class TestTrivialPromptHeuristic: | |
| """Trivial prompts ('ok', 'y', slash commands) must short-circuit injection.""" | |
| def _make_provider(): | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| cfg = HonchoClientConfig(api_key="test-key", enabled=True, recall_mode="hybrid") | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_session = MagicMock() | |
| mock_session.messages = [] | |
| mock_manager.get_or_create.return_value = mock_session | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-session-trivial") | |
| _settle_prewarm(provider) | |
| return provider | |
| def test_classifier_catches_common_trivial_forms(self): | |
| for t in ("ok", "OK", " ok ", "y", "yes", "sure", "thanks", "lgtm", "/help", "", " "): | |
| assert HonchoMemoryProvider._is_trivial_prompt(t), f"expected trivial: {t!r}" | |
| def test_classifier_catches_greetings(self): | |
| """Greeting words must register as trivial so context injection is skipped.""" | |
| for t in ("hi", "HI", "hey", "hello", "yo", "sup", " hi ", "hey!", "hello."): | |
| assert HonchoMemoryProvider._is_trivial_prompt(t), f"expected trivial: {t!r}" | |
| def test_prefetch_skips_on_trivial_prompt(self): | |
| provider = self._make_provider() | |
| provider._session_key = "test" | |
| provider._base_context_cache = "cached base" | |
| provider._last_dialectic_turn = 0 | |
| provider._turn_count = 5 | |
| assert provider.prefetch("ok") == "" | |
| assert provider.prefetch("/help") == "" | |
| # Dialectic should not have fired | |
| assert provider._manager.dialectic_query.call_count == 0 | |
| def test_trivial_prompt_injects_ready_pending_dialectic(self): | |
| """A trivial turn consumes a ready result without starting new work.""" | |
| provider = self._make_provider() | |
| provider._session_key = "test" | |
| provider._base_context_cache = "" # isolate the supplement path | |
| provider._dialectic_cadence = 4 | |
| provider._turn_count = 2 | |
| # Simulate: queue_prefetch fired the dialectic at end of turn 1. | |
| provider._last_dialectic_turn = 1 | |
| with provider._prefetch_lock: | |
| provider._prefetch_result = "PENDING_DIALECTIC" | |
| provider._prefetch_result_fired_at = 1 | |
| injected = provider.prefetch("ok") | |
| assert "PENDING_DIALECTIC" in injected | |
| # And it was consumed, not left to go stale. | |
| with provider._prefetch_lock: | |
| assert provider._prefetch_result == "" | |
| def test_trivial_prompt_discards_stale_pending_dialectic(self): | |
| """A pending result older than cadence × multiplier must still be | |
| discarded on a trivial turn — the fix must not resurrect stale content.""" | |
| provider = self._make_provider() | |
| provider._session_key = "test" | |
| provider._base_context_cache = "" | |
| provider._dialectic_cadence = 4 # stale_limit = 4 * 2 = 8 | |
| provider._last_dialectic_turn = 1 | |
| provider._turn_count = 1 + 4 * provider._STALE_RESULT_MULTIPLIER + 1 # 10 → stale | |
| with provider._prefetch_lock: | |
| provider._prefetch_result = "STALE_DIALECTIC" | |
| provider._prefetch_result_fired_at = 1 | |
| injected = provider.prefetch("ok") | |
| assert injected == "" | |
| with provider._prefetch_lock: | |
| assert provider._prefetch_result == "" | |
| class TestDialecticCadenceAdvancesOnSuccess: | |
| """Cadence tracker advances only when the dialectic call returns a | |
| non-empty result. Empty results (transient API error, sparse representation) | |
| must retry on the next eligible turn instead of waiting the full cadence.""" | |
| def _make_provider(): | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| cfg = HonchoClientConfig( | |
| api_key="test-key", enabled=True, recall_mode="hybrid", dialectic_depth=1, | |
| ) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_session = MagicMock() | |
| mock_session.messages = [] | |
| mock_manager.get_or_create.return_value = mock_session | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-session-retry") | |
| _settle_prewarm(provider) | |
| return provider | |
| def test_non_empty_dialectic_result_advances_cadence(self): | |
| provider = self._make_provider() | |
| provider._session_key = "test" | |
| provider._manager.dialectic_query.return_value = "real synthesis output" | |
| provider._turn_count = 5 | |
| provider._last_dialectic_turn = 0 | |
| provider.queue_prefetch("what changed in the repo today") | |
| if provider._prefetch_thread: | |
| provider._prefetch_thread.join(timeout=2.0) | |
| assert provider._last_dialectic_turn == 5 | |
| def test_in_flight_thread_is_not_stacked(self): | |
| import threading as _threading | |
| import time as _time | |
| provider = self._make_provider() | |
| provider._session_key = "test" | |
| provider._turn_count = 10 | |
| provider._last_dialectic_turn = 0 | |
| # Simulate a prior thread still running (fresh, not stale) | |
| hold = _threading.Event() | |
| def _block(): | |
| hold.wait(timeout=5.0) | |
| fresh = _threading.Thread(target=_block, daemon=True) | |
| fresh.start() | |
| provider._prefetch_thread = fresh | |
| provider._prefetch_thread_started_at = _time.monotonic() # fresh start | |
| provider.queue_prefetch("what changed in the repo today") | |
| # Should have short-circuited — no new dialectic call | |
| assert provider._manager.dialectic_query.call_count == 0 | |
| hold.set() | |
| fresh.join(timeout=2.0) | |
| class TestSessionStartDialecticPrewarm: | |
| """Session-start prewarm fires a depth-aware dialectic whose result is | |
| consumed by turn 1 — no duplicate .chat() and no dead-cache orphaning.""" | |
| def _make_provider(cfg_extra=None, dialectic_result="prewarm synthesis"): | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid") | |
| if cfg_extra: | |
| defaults.update(cfg_extra) | |
| cfg = HonchoClientConfig(**defaults) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_manager.get_or_create.return_value = MagicMock(messages=[]) | |
| mock_manager.get_prefetch_context.return_value = None | |
| mock_manager.pop_context_result.return_value = None | |
| mock_manager.dialectic_query.return_value = dialectic_result | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-prewarm") | |
| return provider | |
| def test_prewarm_populates_prefetch_result(self): | |
| p = self._make_provider() | |
| # Wait for prewarm thread to land | |
| if p._prefetch_thread: | |
| p._prefetch_thread.join(timeout=3.0) | |
| with p._prefetch_lock: | |
| assert p._prefetch_result == "prewarm synthesis" | |
| assert p._last_dialectic_turn == 0 | |
| def test_turn1_consumes_prewarm_without_duplicate_dialectic(self): | |
| """With prewarm result already in _prefetch_result, turn 1 prefetch | |
| should NOT fire another dialectic.""" | |
| p = self._make_provider() | |
| if p._prefetch_thread: | |
| p._prefetch_thread.join(timeout=3.0) | |
| p._manager.dialectic_query.reset_mock() | |
| p._session_key = "test-prewarm" | |
| p._base_context_cache = "" | |
| p._turn_count = 1 | |
| result = p.prefetch("hello world") | |
| assert "prewarm synthesis" in result | |
| # The sync first-turn path must NOT have fired another .chat() | |
| assert p._manager.dialectic_query.call_count == 0 | |
| class TestDialecticLiveness: | |
| """Liveness + observability: stale-thread recovery, stale-result discard, | |
| empty-streak backoff, and the snapshot method used for diagnostics.""" | |
| def _make_provider(cfg_extra=None): | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid", timeout=2.0) | |
| if cfg_extra: | |
| defaults.update(cfg_extra) | |
| cfg = HonchoClientConfig(**defaults) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_manager.get_or_create.return_value = MagicMock(messages=[]) | |
| mock_manager.get_prefetch_context.return_value = None | |
| mock_manager.pop_context_result.return_value = None | |
| mock_manager.dialectic_query.return_value = "" # default: silent | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-liveness") | |
| _settle_prewarm(provider) | |
| return provider | |
| def test_stale_thread_is_treated_as_dead(self): | |
| """A thread older than timeout × multiplier no longer blocks new fires.""" | |
| import threading as _threading | |
| p = self._make_provider() | |
| p._session_key = "test" | |
| p._turn_count = 10 | |
| p._last_dialectic_turn = 0 | |
| p._manager.dialectic_query.return_value = "fresh synthesis" | |
| # Plant an alive thread with an old timestamp (stale) | |
| hold = _threading.Event() | |
| stuck = _threading.Thread(target=lambda: hold.wait(timeout=10.0), daemon=True) | |
| stuck.start() | |
| p._prefetch_thread = stuck | |
| # timeout=2.0, multiplier=2.0, so anything older than 4s is stale | |
| p._prefetch_thread_started_at = 0.0 # very old (1970 monotonic baseline) | |
| p.queue_prefetch("what changed in the repo today") | |
| # New thread should have been spawned since stuck one is stale | |
| assert p._prefetch_thread is not stuck, "stale thread must be recycled" | |
| if p._prefetch_thread: | |
| p._prefetch_thread.join(timeout=2.0) | |
| assert p._manager.dialectic_query.call_count == 1 | |
| hold.set() | |
| stuck.join(timeout=2.0) | |
| def test_empty_streak_widens_effective_cadence(self): | |
| """After N empty returns, the gate waits cadence + N turns.""" | |
| p = self._make_provider(cfg_extra={"dialectic_cadence": 1}) | |
| p._dialectic_empty_streak = 3 | |
| # cadence=1, streak=3 → effective = 4 | |
| assert p._effective_cadence() == 4 | |
| class TestDialecticLifecycleSmoke: | |
| """End-to-end smoke walking a multi-turn session through prewarm, | |
| turn 1 consume, trivial skip, cadence fire, empty-result retry, | |
| heuristic bump, and session-end flush.""" | |
| def _make_provider(cfg_extra=None): | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| defaults = dict( | |
| api_key="test-key", enabled=True, recall_mode="hybrid", | |
| dialectic_reasoning_level="low", reasoning_heuristic=True, | |
| reasoning_level_cap="high", dialectic_depth=1, | |
| ) | |
| if cfg_extra: | |
| defaults.update(cfg_extra) | |
| cfg = HonchoClientConfig(**defaults) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_session = MagicMock() | |
| mock_session.messages = [] | |
| mock_manager.get_or_create.return_value = mock_session | |
| mock_manager.get_prefetch_context.return_value = None | |
| mock_manager.pop_context_result.return_value = None | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| return provider, mock_manager, cfg | |
| def _await_thread(self, provider): | |
| """Wait up to 30 seconds and fail clearly if background work hangs.""" | |
| thread = provider._prefetch_thread | |
| if thread is None: | |
| return | |
| deadline = time.monotonic() + 30.0 | |
| while thread.is_alive() and time.monotonic() < deadline: | |
| thread.join(timeout=1.0) | |
| assert not thread.is_alive(), ( | |
| "prefetch/prewarm thread did not finish within 30s — " | |
| "this is a real hang, not a timing flake" | |
| ) | |
| def test_full_multi_turn_session(self): | |
| """Walks init → turns 1..8 → session end. Asserts at every step that | |
| the plugin did exactly what it should and nothing more. | |
| Uses dialecticCadence=3 so we can exercise skip-turns between fires | |
| and the silent-failure retry path without their gates tripping each | |
| other. Trivial + slash skips apply independent of cadence. | |
| """ | |
| from unittest.mock import patch, MagicMock | |
| provider, mgr, cfg = self._make_provider( | |
| cfg_extra={"dialectic_cadence": 3} | |
| ) | |
| # Program the dialectic responses in the exact order they'll be requested. | |
| # An extra or missing call fails the test — strong smoke signal. | |
| responses = iter([ | |
| "prewarm: user is eri, works on hermes", # session-start prewarm | |
| "cadence fire: long query synthesis", # turn 4 queue_prefetch | |
| "", # turn 7 fire: silent failure | |
| "retry success: fresh synthesis", # turn 8 queue_prefetch retry | |
| ]) | |
| mgr.dialectic_query.side_effect = lambda *a, **kw: next(responses) | |
| # ---- init: prewarm fires ---- | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mgr), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="smoke-test") | |
| self._await_thread(provider) | |
| with provider._prefetch_lock: | |
| assert provider._prefetch_result.startswith("prewarm"), \ | |
| "session-start prewarm must land in _prefetch_result" | |
| assert provider._last_dialectic_turn == 0, "prewarm marks turn 0" | |
| assert mgr.dialectic_query.call_count == 1 | |
| # ---- turn 1: consume prewarm, no duplicate dialectic ---- | |
| provider.on_turn_start(1, "hey") | |
| inject1 = provider.prefetch("hey") | |
| assert "prewarm" in inject1, "turn 1 must surface prewarm" | |
| provider.sync_turn("hey", "hi there") | |
| provider.queue_prefetch("hey") # cadence gate: (1-0)<3 → skip | |
| self._await_thread(provider) | |
| assert mgr.dialectic_query.call_count == 1, \ | |
| "turn 1 must not fire — prewarm covered it and cadence skips" | |
| # ---- turn 2: trivial 'ok' → skip everything ---- | |
| mgr.prefetch_context.reset_mock() | |
| provider.on_turn_start(2, "ok") | |
| assert provider.prefetch("ok") == "", "trivial prompt must short-circuit injection" | |
| provider.sync_turn("ok", "cool") | |
| provider.queue_prefetch("ok") | |
| self._await_thread(provider) | |
| assert mgr.dialectic_query.call_count == 1, "trivial must not fire dialectic" | |
| assert mgr.prefetch_context.call_count == 0, "trivial must not fire context refresh" | |
| # ---- turn 3: slash '/help' → also skip ---- | |
| provider.on_turn_start(3, "/help") | |
| assert provider.prefetch("/help") == "" | |
| provider.queue_prefetch("/help") | |
| assert mgr.dialectic_query.call_count == 1 | |
| # ---- turn 4: long query → cadence fires + heuristic bumps ---- | |
| long_q = "walk me through " + ("x " * 100) # ~200 chars → heuristic +1 | |
| provider.on_turn_start(4, long_q) | |
| provider.prefetch(long_q) | |
| provider.sync_turn(long_q, "sure") | |
| provider.queue_prefetch(long_q) # (4-0)≥3 → fires | |
| self._await_thread(provider) | |
| assert mgr.dialectic_query.call_count == 2, "turn 4 cadence fire" | |
| _, kwargs = mgr.dialectic_query.call_args | |
| assert kwargs.get("reasoning_level") in {"medium", "high"}, \ | |
| f"long query must bump reasoning level above 'low'; got {kwargs.get('reasoning_level')}" | |
| assert provider._last_dialectic_turn == 4, "cadence tracker advances on success" | |
| # ---- turns 5–6: cadence cooldown, no fires ---- | |
| for t in (5, 6): | |
| provider.on_turn_start(t, "tell me more") | |
| provider.queue_prefetch("tell me more") | |
| self._await_thread(provider) | |
| assert mgr.dialectic_query.call_count == 2, "turns 5–6 blocked by cadence window" | |
| # ---- turn 7: fires but silent failure (empty dialectic) ---- | |
| provider.on_turn_start(7, "and then what") | |
| provider.queue_prefetch("and then what") # (7-4)≥3 → fires | |
| self._await_thread(provider) | |
| assert mgr.dialectic_query.call_count == 3, "turn 7 fires" | |
| assert provider._last_dialectic_turn == 4, \ | |
| "silent failure must NOT burn the cadence window" | |
| # ---- turn 8: retries because cadence didn't advance ---- | |
| provider.on_turn_start(8, "try again") | |
| provider.queue_prefetch("try again") # (8-4)≥3 → fires again | |
| self._await_thread(provider) | |
| assert mgr.dialectic_query.call_count == 4, \ | |
| "turn 8 retries because turn 7's empty result didn't advance cadence" | |
| assert provider._last_dialectic_turn == 8, "retry success advances" | |
| # ---- session end: flush messages ---- | |
| provider.on_session_end([]) | |
| mgr.flush_all.assert_called() | |
| class TestReasoningHeuristic: | |
| """Char-count heuristic that scales the auto-injected reasoning level by | |
| query length, clamped at reasoning_level_cap.""" | |
| def _make_provider(cfg_extra=None): | |
| from unittest.mock import patch, MagicMock | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| defaults = dict( | |
| api_key="test-key", enabled=True, recall_mode="hybrid", | |
| dialectic_reasoning_level="low", reasoning_heuristic=True, | |
| reasoning_level_cap="high", | |
| ) | |
| if cfg_extra: | |
| defaults.update(cfg_extra) | |
| cfg = HonchoClientConfig(**defaults) | |
| provider = HonchoMemoryProvider() | |
| mock_manager = MagicMock() | |
| mock_manager.get_or_create.return_value = MagicMock(messages=[]) | |
| with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ | |
| patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ | |
| patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ | |
| patch("hermes_constants.get_hermes_home", return_value=MagicMock()): | |
| provider.initialize(session_id="test-heuristic") | |
| _settle_prewarm(provider) | |
| return provider | |
| def test_heuristic_disabled_returns_base(self): | |
| p = self._make_provider(cfg_extra={"reasoning_heuristic": False}) | |
| q = "x" * 500 | |
| assert p._apply_reasoning_heuristic("low", q) == "low" | |
| # --------------------------------------------------------------------------- | |
| # set_peer_card None guard | |
| # --------------------------------------------------------------------------- | |
| class TestSetPeerCardNoneGuard: | |
| """set_peer_card must return None (not raise) when peer ID cannot be resolved.""" | |
| def _make_manager(self): | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| from plugins.memory.honcho.session import HonchoSessionManager | |
| cfg = HonchoClientConfig(api_key="test-key", enabled=True) | |
| mgr = HonchoSessionManager.__new__(HonchoSessionManager) | |
| mgr._cache = {} | |
| mgr._cache_lock = threading.RLock() | |
| mgr._sessions_cache = {} | |
| mgr._config = cfg | |
| mgr._session_observation = {} | |
| return mgr | |
| def test_returns_none_when_peer_resolves_to_none(self): | |
| """set_peer_card returns None when _resolve_peer_id returns None.""" | |
| from unittest.mock import patch | |
| mgr = self._make_manager() | |
| session = HonchoSession( | |
| key="test", | |
| honcho_session_id="sid", | |
| user_peer_id="user-peer", | |
| assistant_peer_id="ai-peer", | |
| ) | |
| mgr._cache["test"] = session | |
| with patch.object(mgr, "_resolve_peer_id", return_value=None): | |
| result = mgr.set_peer_card("test", ["fact 1", "fact 2"], peer="ghost") | |
| assert result is None | |
| # --------------------------------------------------------------------------- | |
| # get_session_context cache-miss fallback respects peer param | |
| # --------------------------------------------------------------------------- | |
| class TestGetSessionContextFallback: | |
| """get_session_context fallback must honour the peer param when honcho_session is absent.""" | |
| def _make_manager_with_session(self, user_peer_id="user-peer", assistant_peer_id="ai-peer"): | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| from plugins.memory.honcho.session import HonchoSessionManager | |
| cfg = HonchoClientConfig(api_key="test-key", enabled=True) | |
| mgr = HonchoSessionManager.__new__(HonchoSessionManager) | |
| mgr._cache = {} | |
| mgr._cache_lock = threading.RLock() | |
| mgr._sessions_cache = {} | |
| mgr._config = cfg | |
| mgr._dialectic_dynamic = True | |
| mgr._dialectic_reasoning_level = "low" | |
| mgr._dialectic_max_input_chars = 10000 | |
| mgr._ai_observe_others = True | |
| mgr._session_observation = {} | |
| session = HonchoSession( | |
| key="test", | |
| honcho_session_id="sid-missing-from-sessions-cache", | |
| user_peer_id=user_peer_id, | |
| assistant_peer_id=assistant_peer_id, | |
| ) | |
| mgr._cache["test"] = session | |
| # Deliberately NOT adding to _sessions_cache to trigger fallback path | |
| return mgr | |
| def test_fallback_uses_user_peer_for_user(self): | |
| """On cache miss, peer='user' fetches user peer context.""" | |
| mgr = self._make_manager_with_session() | |
| fetch_calls = [] | |
| def _fake_fetch(peer_id, search_query=None, *, target=None): | |
| fetch_calls.append((peer_id, target)) | |
| return {"representation": "user rep", "card": []} | |
| mgr._fetch_peer_context = _fake_fetch | |
| mgr.get_session_context("test", peer="user") | |
| assert len(fetch_calls) == 1 | |
| peer_id, target = fetch_calls[0] | |
| assert peer_id == "user-peer" | |
| assert target == "user-peer" | |
| # --------------------------------------------------------------------------- | |
| # contextTokens must reach session.context() (salvage of #70951) | |
| # --------------------------------------------------------------------------- | |
| class TestContextTokensForwarded: | |
| """Honcho picks the short or long summary from the tokens= budget. get_prefetch_context and | |
| get_session_context called context(summary=True) without it, so a configured contextTokens | |
| cap was ignored and every turn got the long summary.""" | |
| def _manager(self, context_tokens=4000): | |
| mgr = HonchoSessionManager(context_tokens=context_tokens) | |
| session = HonchoSession(key="cli:test", user_peer_id="robert", assistant_peer_id="hermes", | |
| honcho_session_id="sess-1") | |
| mgr._cache[session.key] = session | |
| honcho_session = MagicMock() | |
| honcho_session.context.return_value = SimpleNamespace( | |
| summary=SimpleNamespace(content="short summary"), peer_representation="rep", peer_card=["fact"], | |
| messages=[]) | |
| mgr._sessions_cache[session.honcho_session_id] = honcho_session | |
| mgr._fetch_peer_context = MagicMock(return_value={"representation": "", "card": []}) | |
| return mgr, session, honcho_session | |
| def test_get_prefetch_context_passes_context_tokens_to_summary_call(self): | |
| mgr, session, honcho_session = self._manager() | |
| result = mgr.get_prefetch_context(session.key) | |
| assert result["summary"] == "short summary" | |
| honcho_session.context.assert_called_once_with(summary=True, tokens=4000) | |
| def test_get_session_context_passes_context_tokens_to_cached_session_call(self): | |
| mgr, session, honcho_session = self._manager() | |
| result = mgr.get_session_context(session.key, peer="user") | |
| assert result["summary"] == "short summary" | |
| honcho_session.context.assert_called_once_with( | |
| summary=True, tokens=4000, peer_target=session.user_peer_id, peer_perspective=session.assistant_peer_id) | |
| # --------------------------------------------------------------------------- | |
| # injection.sessionStart pins which base-context components render | |
| # --------------------------------------------------------------------------- | |
| _FULL_CTX = { | |
| "summary": "sum", "representation": "rep", "card": "card", | |
| "ai_representation": "ai-rep", "ai_card": "ai-card", | |
| } | |
| def _provider_with_raw(raw, host="hermes"): | |
| from plugins.memory.honcho.client import HonchoClientConfig, _host_block, _HostLookup | |
| provider = HonchoMemoryProvider() | |
| look = _HostLookup(_host_block(raw, host), raw) | |
| provider._session_start_components = provider._resolve_session_start(look) | |
| provider._injection_log_path = provider._resolve_injection_log_path(look) | |
| provider._config = HonchoClientConfig(api_key="k", enabled=True, raw=raw, host=host) | |
| return provider | |
| class TestSessionStartInjection: | |
| def test_pin_selects_the_rendered_components(self, raw, headings): | |
| formatted = _provider_with_raw(raw)._format_first_turn_context(_FULL_CTX) | |
| assert [line for line in formatted.splitlines() if line.startswith("## ")] == headings | |
| def test_desktop_panel_writes_the_pin_the_provider_reads(self, submitted, headings): | |
| from hermes_cli.web_routers.memory_providers import _apply_field_values | |
| from plugins.memory.honcho.config_schema import CONFIG_SCHEMA | |
| host_block = {"injection": {"sessionStart": ["summary"]}} | |
| _apply_field_values(CONFIG_SCHEMA, {"injection": submitted}, lambda field: host_block) | |
| raw = {"hosts": {"hermes": host_block}} | |
| formatted = _provider_with_raw(raw)._format_first_turn_context(_FULL_CTX) | |
| assert [line for line in formatted.splitlines() if line.startswith("## ")] == headings | |
| def test_non_list_value_is_treated_as_unset(self): | |
| raw = {"injection": {"sessionStart": "summary"}} | |
| assert _provider_with_raw(raw)._session_start_components is None | |
| def test_initialize_reads_the_pin(self): | |
| raw = {"injection": {"sessionStart": ["summary"]}} | |
| provider = TestDialecticCadenceDefaults._make_provider(cfg_extra={"raw": raw, "host": "hermes"}) | |
| assert provider._session_start_components == frozenset({"summary"}) | |
| # --------------------------------------------------------------------------- | |
| # the logging key switches on the injection audit. It is off by default and never raises | |
| # --------------------------------------------------------------------------- | |
| class TestInjectionAuditLog: | |
| def _no_logging_env(self, monkeypatch): | |
| monkeypatch.delenv("HONCHO_LOGGING", raising=False) | |
| monkeypatch.delenv("HONCHO_INJECTION_LOG", raising=False) | |
| def test_stays_off(self, monkeypatch, raw, env): | |
| if env is not None: | |
| monkeypatch.setenv("HONCHO_LOGGING", env) | |
| assert _provider_with_raw(raw)._injection_log_path is None | |
| def test_logging_key_switches_on_the_default_path(self, value): | |
| path = _provider_with_raw({"logging": value})._injection_log_path | |
| assert path is not None and path.endswith("injection.log") | |
| def test_log_file_is_owner_only(self, tmp_path): | |
| provider = _provider_with_raw({}) | |
| provider._injection_log_path = str(tmp_path / "injection.log") | |
| provider._log_injection("injected", "payload") | |
| assert (tmp_path / "injection.log").stat().st_mode & 0o777 == 0o600 | |
| def test_explicit_path_env_overrides_destination(self, monkeypatch, tmp_path): | |
| monkeypatch.setenv("HONCHO_INJECTION_LOG", str(tmp_path / "audit.log")) | |
| assert _provider_with_raw({})._injection_log_path == str(tmp_path / "audit.log") | |
| def test_record_carries_reason_turn_and_payload(self, tmp_path): | |
| provider = _provider_with_raw({}) | |
| provider._injection_log_path = str(tmp_path / "nested" / "injection.log") | |
| provider._turn_count = 3 | |
| provider._session_key = "cli:test" | |
| assert provider._log_injection("injected", "## User Peer Card\nName: Eri") == "## User Peer Card\nName: Eri" | |
| assert provider._log_injection("trivial-prompt") == "" | |
| records = [json.loads(line) for line in (tmp_path / "nested" / "injection.log").read_text().splitlines()] | |
| assert [r["reason"] for r in records] == ["injected", "trivial-prompt"] | |
| assert records[0]["turn"] == 3 and records[0]["session_key"] == "cli:test" | |
| assert records[0]["bytes"] == len("## User Peer Card\nName: Eri".encode()) and records[1]["bytes"] == 0 | |
| def test_unwritable_path_never_raises(self, tmp_path): | |
| provider = _provider_with_raw({}) | |
| blocker = tmp_path / "file" | |
| blocker.write_text("x") | |
| provider._injection_log_path = str(blocker / "injection.log") | |
| assert provider._log_injection("injected", "payload") == "payload" | |
| def test_tools_mode_prefetch_logs_its_reason(self, tmp_path): | |
| provider = _provider_with_raw({}) | |
| provider._injection_log_path = str(tmp_path / "injection.log") | |
| provider._recall_mode = "tools" | |
| assert provider.prefetch("hello") == "" | |
| record = json.loads((tmp_path / "injection.log").read_text().splitlines()[0]) | |
| assert record["reason"] == "cron-or-tools-mode" and record["payload"] == "" | |
| # Observation flags are scoped per session, not manager-wide (#98936) | |
| # --------------------------------------------------------------------------- | |
| class _FakeServerPeerConfig: | |
| """SessionPeerConfig stand-in for both the local build and the server read. | |
| None means "leave unchanged", mirroring the SDK's optional fields. Doubles | |
| as the injected ``honcho.session`` module's SessionPeerConfig so the test | |
| runs even without the optional honcho-ai extra installed. | |
| """ | |
| def __init__(self, observe_me=None, observe_others=None): | |
| self.observe_me = observe_me | |
| self.observe_others = observe_others | |
| class _FakeSdkSession: | |
| """Records add_peers calls and serves per-peer server configs.""" | |
| def __init__(self, server_user_cfg, server_ai_cfg): | |
| self.add_peers_calls = [] | |
| self._server_user_cfg = server_user_cfg | |
| self._server_ai_cfg = server_ai_cfg | |
| def add_peers(self, entries): | |
| self.add_peers_calls.append(entries) | |
| def get_peer_configuration(self, peer): | |
| return self._server_user_cfg if peer == "user-peer" else self._server_ai_cfg | |
| def context(self, summary=True, tokens=None): | |
| class _Ctx: | |
| messages = [] | |
| return _Ctx() | |
| class TestObservationPerSessionScoping: | |
| """One session's server sync must not retune another session's routing.""" | |
| def _make_manager(self): | |
| from plugins.memory.honcho.session import HonchoSessionManager | |
| mgr = HonchoSessionManager.__new__(HonchoSessionManager) | |
| mgr._cache = {} | |
| mgr._sessions_cache = {} | |
| mgr._session_observation = {} | |
| mgr._cache_lock = threading.RLock() | |
| mgr._context_tokens = 1000 | |
| # Config snapshot defaults — manager fields must stay at these values. | |
| mgr._user_observe_me = True | |
| mgr._user_observe_others = True | |
| mgr._ai_observe_me = True | |
| mgr._ai_observe_others = True | |
| mgr._authed_call = lambda label, op: op() | |
| return mgr | |
| def _session(self, mgr, key): | |
| session = HonchoSession( | |
| key=key, | |
| honcho_session_id=f"sid-{key}", | |
| user_peer_id="user-peer", | |
| assistant_peer_id="ai-peer", | |
| ) | |
| mgr._cache[key] = session | |
| return session | |
| def _setup_session(self, mgr, session_id, fake_sdk): | |
| fake_module = SimpleNamespace(SessionPeerConfig=_FakeServerPeerConfig) | |
| mgr._sdk_session = lambda sid: fake_sdk | |
| with patch.dict(sys.modules, {"honcho.session": fake_module}): | |
| return mgr._get_or_create_honcho_session( | |
| session_id, "user-peer", "ai-peer" | |
| ) | |
| def test_sync_back_scopes_flags_per_session(self): | |
| """Server flags come back per session; manager snapshot untouched.""" | |
| mgr = self._make_manager() | |
| # Session A's server config disables user observe_others... | |
| _, _, flags_a = self._setup_session( | |
| mgr, "sid-a", | |
| _FakeSdkSession(_FakeServerPeerConfig(observe_others=False), _FakeServerPeerConfig()), | |
| ) | |
| # ...session B's server leaves everything at the synced-in defaults. | |
| _, _, flags_b = self._setup_session( | |
| mgr, "sid-b", | |
| _FakeSdkSession(_FakeServerPeerConfig(), _FakeServerPeerConfig()), | |
| ) | |
| assert flags_a["user_observe_others"] is False | |
| assert flags_b["user_observe_others"] is True | |
| # The config snapshot on the manager must survive both syncs — this is | |
| # the regression: last-session-wins used to overwrite it (#98936). | |
| assert mgr._user_observe_others is True | |
| def test_add_peers_reuses_synced_flags_for_existing_session(self): | |
| """A re-initialized session re-applies its own synced values, not the defaults.""" | |
| mgr = self._make_manager() | |
| fake = _FakeSdkSession( | |
| _FakeServerPeerConfig(observe_others=False), _FakeServerPeerConfig() | |
| ) | |
| _, _, flags = self._setup_session(mgr, "sid-a", fake) | |
| mgr._session_observation["sid-a"] = flags # what get_or_create stores next to the cache entry | |
| # Force the full setup path again (cache cleared, e.g. after re-auth). | |
| mgr._sessions_cache = {} | |
| fake2 = _FakeSdkSession( | |
| _FakeServerPeerConfig(observe_others=False), _FakeServerPeerConfig() | |
| ) | |
| self._setup_session(mgr, "sid-a", fake2) | |
| user_cfg = fake2.add_peers_calls[0][0][1] | |
| assert user_cfg.observe_others is False | |
| def test_resolve_observer_target_uses_own_session_flags(self): | |
| """Recall routing reads each session's flags, so diverging sessions diverge.""" | |
| mgr = self._make_manager() | |
| session_a = self._session(mgr, "a") | |
| session_b = self._session(mgr, "b") | |
| mgr._session_observation["sid-a"] = { | |
| "user_observe_me": True, | |
| "user_observe_others": True, | |
| "ai_observe_me": True, | |
| "ai_observe_others": False, | |
| } | |
| mgr._session_observation["sid-b"] = { | |
| "user_observe_me": True, | |
| "user_observe_others": True, | |
| "ai_observe_me": True, | |
| "ai_observe_others": True, | |
| } | |
| # Without AI cross-observation the target peer queries its own context. | |
| assert mgr._resolve_observer_target(session_a, "user") == ("user-peer", None) | |
| # With it, the assistant peer observes the user peer. | |
| assert mgr._resolve_observer_target(session_b, "user") == ("ai-peer", "user-peer") | |
| def test_unsynced_session_falls_back_to_config_snapshot(self): | |
| """A session that never completed setup routes with the config defaults.""" | |
| mgr = self._make_manager() | |
| session_c = self._session(mgr, "c") | |
| mgr._session_observation["sid-other"] = { | |
| "user_observe_me": True, | |
| "user_observe_others": True, | |
| "ai_observe_me": True, | |
| "ai_observe_others": False, | |
| } | |
| assert mgr._ai_observes_others(session_c) is True | |