feat(honcho): async memory integration with prefetch pipeline and recallMode
Adds full Honcho memory integration to Hermes: - Session manager with async background writes, memory modes (honcho/hybrid/local), and dialectic prefetch for first-turn context warming - Agent integration: prefetch pipeline, tool surface gated by recallMode, system prompt context injection, SIGTERM/SIGINT flush handlers - CLI commands: setup, status, mode, tokens, peer, identity, migrate - recallMode setting (auto | context | tools) for A/B testing retrieval strategies - Session strategies: per-session, per-repo (git tree root), per-directory, global - Polymorphic memoryMode config: string shorthand or per-peer object overrides - 97 tests covering async writes, client config, session resolution, and memory modes
This commit is contained in:
parent
8eefbef91c
commit
74c214e957
17 changed files with 2478 additions and 135 deletions
489
tests/honcho_integration/test_async_memory.py
Normal file
489
tests/honcho_integration/test_async_memory.py
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
"""Tests for the async-memory Honcho improvements.
|
||||
|
||||
Covers:
|
||||
- write_frequency parsing (async / turn / session / int)
|
||||
- memory_mode parsing
|
||||
- resolve_session_name with session_title
|
||||
- HonchoSessionManager.save() routing per write_frequency
|
||||
- async writer thread lifecycle and retry
|
||||
- flush_all() drains pending messages
|
||||
- shutdown() joins the thread
|
||||
- memory_mode gating helpers (unit-level)
|
||||
"""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_integration.client import HonchoClientConfig
|
||||
from honcho_integration.session import (
|
||||
HonchoSession,
|
||||
HonchoSessionManager,
|
||||
_ASYNC_SHUTDOWN,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_session(**kwargs) -> HonchoSession:
|
||||
return HonchoSession(
|
||||
key=kwargs.get("key", "cli:test"),
|
||||
user_peer_id=kwargs.get("user_peer_id", "eri"),
|
||||
assistant_peer_id=kwargs.get("assistant_peer_id", "hermes"),
|
||||
honcho_session_id=kwargs.get("honcho_session_id", "cli-test"),
|
||||
messages=kwargs.get("messages", []),
|
||||
)
|
||||
|
||||
|
||||
def _make_manager(write_frequency="turn", memory_mode="hybrid") -> HonchoSessionManager:
|
||||
cfg = HonchoClientConfig(
|
||||
write_frequency=write_frequency,
|
||||
memory_mode=memory_mode,
|
||||
api_key="test-key",
|
||||
enabled=True,
|
||||
)
|
||||
mgr = HonchoSessionManager(config=cfg)
|
||||
mgr._honcho = MagicMock()
|
||||
return mgr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_frequency parsing from config file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWriteFrequencyParsing:
|
||||
def test_string_async(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "writeFrequency": "async"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.write_frequency == "async"
|
||||
|
||||
def test_string_turn(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "writeFrequency": "turn"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.write_frequency == "turn"
|
||||
|
||||
def test_string_session(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "writeFrequency": "session"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.write_frequency == "session"
|
||||
|
||||
def test_integer_frequency(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "writeFrequency": 5}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.write_frequency == 5
|
||||
|
||||
def test_integer_string_coerced(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "writeFrequency": "3"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.write_frequency == 3
|
||||
|
||||
def test_host_block_overrides_root(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"writeFrequency": "turn",
|
||||
"hosts": {"hermes": {"writeFrequency": "session"}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.write_frequency == "session"
|
||||
|
||||
def test_defaults_to_async(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.write_frequency == "async"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# memory_mode parsing from config file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMemoryModeParsing:
|
||||
def test_hybrid(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "memoryMode": "hybrid"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
|
||||
def test_honcho_only(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "memoryMode": "honcho"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "honcho"
|
||||
|
||||
def test_local_only(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "memoryMode": "local"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "local"
|
||||
|
||||
def test_defaults_to_hybrid(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
|
||||
def test_host_block_overrides_root(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"memoryMode": "hybrid",
|
||||
"hosts": {"hermes": {"memoryMode": "honcho"}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "honcho"
|
||||
|
||||
def test_object_form_sets_default_and_overrides(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"hosts": {"hermes": {"memoryMode": {
|
||||
"default": "hybrid",
|
||||
"hermes": "honcho",
|
||||
"sentinel": "local",
|
||||
}}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
assert cfg.peer_memory_mode("hermes") == "honcho"
|
||||
assert cfg.peer_memory_mode("sentinel") == "local"
|
||||
assert cfg.peer_memory_mode("unknown") == "hybrid" # falls through to default
|
||||
|
||||
def test_object_form_no_default_falls_back_to_hybrid(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"hosts": {"hermes": {"memoryMode": {"hermes": "honcho"}}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
assert cfg.peer_memory_mode("hermes") == "honcho"
|
||||
assert cfg.peer_memory_mode("other") == "hybrid"
|
||||
|
||||
def test_global_string_host_object_override(self, tmp_path):
|
||||
"""Host object form overrides global string."""
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"memoryMode": "local",
|
||||
"hosts": {"hermes": {"memoryMode": {"default": "hybrid", "hermes": "honcho"}}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid" # host default wins over global "local"
|
||||
assert cfg.peer_memory_mode("hermes") == "honcho"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_session_name with session_title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveSessionNameTitle:
|
||||
def test_manual_override_beats_title(self):
|
||||
cfg = HonchoClientConfig(sessions={"/my/project": "manual-name"})
|
||||
result = cfg.resolve_session_name("/my/project", session_title="the-title")
|
||||
assert result == "manual-name"
|
||||
|
||||
def test_title_beats_dirname(self):
|
||||
cfg = HonchoClientConfig()
|
||||
result = cfg.resolve_session_name("/some/dir", session_title="my-project")
|
||||
assert result == "my-project"
|
||||
|
||||
def test_title_with_peer_prefix(self):
|
||||
cfg = HonchoClientConfig(peer_name="eri", session_peer_prefix=True)
|
||||
result = cfg.resolve_session_name("/some/dir", session_title="aeris")
|
||||
assert result == "eri-aeris"
|
||||
|
||||
def test_title_sanitized(self):
|
||||
cfg = HonchoClientConfig()
|
||||
result = cfg.resolve_session_name("/some/dir", session_title="my project/name!")
|
||||
# trailing dashes stripped by .strip('-')
|
||||
assert result == "my-project-name"
|
||||
|
||||
def test_title_all_invalid_chars_falls_back_to_dirname(self):
|
||||
cfg = HonchoClientConfig()
|
||||
result = cfg.resolve_session_name("/some/dir", session_title="!!! ###")
|
||||
# sanitized to empty → falls back to dirname
|
||||
assert result == "dir"
|
||||
|
||||
def test_none_title_falls_back_to_dirname(self):
|
||||
cfg = HonchoClientConfig()
|
||||
result = cfg.resolve_session_name("/some/dir", session_title=None)
|
||||
assert result == "dir"
|
||||
|
||||
def test_empty_title_falls_back_to_dirname(self):
|
||||
cfg = HonchoClientConfig()
|
||||
result = cfg.resolve_session_name("/some/dir", session_title="")
|
||||
assert result == "dir"
|
||||
|
||||
def test_per_session_uses_session_id(self):
|
||||
cfg = HonchoClientConfig(session_strategy="per-session")
|
||||
result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd")
|
||||
assert result == "20260309_175514_9797dd"
|
||||
|
||||
def test_per_session_with_peer_prefix(self):
|
||||
cfg = HonchoClientConfig(session_strategy="per-session", peer_name="eri", session_peer_prefix=True)
|
||||
result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd")
|
||||
assert result == "eri-20260309_175514_9797dd"
|
||||
|
||||
def test_per_session_no_id_falls_back_to_dirname(self):
|
||||
cfg = HonchoClientConfig(session_strategy="per-session")
|
||||
result = cfg.resolve_session_name("/some/dir", session_id=None)
|
||||
assert result == "dir"
|
||||
|
||||
def test_title_beats_session_id(self):
|
||||
cfg = HonchoClientConfig(session_strategy="per-session")
|
||||
result = cfg.resolve_session_name("/some/dir", session_title="my-title", session_id="20260309_175514_9797dd")
|
||||
assert result == "my-title"
|
||||
|
||||
def test_manual_beats_session_id(self):
|
||||
cfg = HonchoClientConfig(session_strategy="per-session", sessions={"/some/dir": "pinned"})
|
||||
result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd")
|
||||
assert result == "pinned"
|
||||
|
||||
def test_global_strategy_returns_workspace(self):
|
||||
cfg = HonchoClientConfig(session_strategy="global", workspace_id="my-workspace")
|
||||
result = cfg.resolve_session_name("/some/dir")
|
||||
assert result == "my-workspace"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# save() routing per write_frequency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSaveRouting:
|
||||
def _make_session_with_message(self, mgr=None):
|
||||
sess = _make_session()
|
||||
sess.add_message("user", "hello")
|
||||
sess.add_message("assistant", "hi")
|
||||
if mgr:
|
||||
mgr._cache[sess.key] = sess
|
||||
return sess
|
||||
|
||||
def test_turn_flushes_immediately(self):
|
||||
mgr = _make_manager(write_frequency="turn")
|
||||
sess = self._make_session_with_message(mgr)
|
||||
with patch.object(mgr, "_flush_session") as mock_flush:
|
||||
mgr.save(sess)
|
||||
mock_flush.assert_called_once_with(sess)
|
||||
|
||||
def test_session_mode_does_not_flush(self):
|
||||
mgr = _make_manager(write_frequency="session")
|
||||
sess = self._make_session_with_message(mgr)
|
||||
with patch.object(mgr, "_flush_session") as mock_flush:
|
||||
mgr.save(sess)
|
||||
mock_flush.assert_not_called()
|
||||
|
||||
def test_async_mode_enqueues(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
sess = self._make_session_with_message(mgr)
|
||||
with patch.object(mgr, "_flush_session") as mock_flush:
|
||||
mgr.save(sess)
|
||||
# flush_session should NOT be called synchronously
|
||||
mock_flush.assert_not_called()
|
||||
assert not mgr._async_queue.empty()
|
||||
|
||||
def test_int_frequency_flushes_on_nth_turn(self):
|
||||
mgr = _make_manager(write_frequency=3)
|
||||
sess = self._make_session_with_message(mgr)
|
||||
with patch.object(mgr, "_flush_session") as mock_flush:
|
||||
mgr.save(sess) # turn 1
|
||||
mgr.save(sess) # turn 2
|
||||
assert mock_flush.call_count == 0
|
||||
mgr.save(sess) # turn 3
|
||||
assert mock_flush.call_count == 1
|
||||
|
||||
def test_int_frequency_skips_other_turns(self):
|
||||
mgr = _make_manager(write_frequency=5)
|
||||
sess = self._make_session_with_message(mgr)
|
||||
with patch.object(mgr, "_flush_session") as mock_flush:
|
||||
for _ in range(4):
|
||||
mgr.save(sess)
|
||||
assert mock_flush.call_count == 0
|
||||
mgr.save(sess) # turn 5
|
||||
assert mock_flush.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# flush_all()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFlushAll:
|
||||
def test_flushes_all_cached_sessions(self):
|
||||
mgr = _make_manager(write_frequency="session")
|
||||
s1 = _make_session(key="s1", honcho_session_id="s1")
|
||||
s2 = _make_session(key="s2", honcho_session_id="s2")
|
||||
s1.add_message("user", "a")
|
||||
s2.add_message("user", "b")
|
||||
mgr._cache = {"s1": s1, "s2": s2}
|
||||
|
||||
with patch.object(mgr, "_flush_session") as mock_flush:
|
||||
mgr.flush_all()
|
||||
assert mock_flush.call_count == 2
|
||||
|
||||
def test_flush_all_drains_async_queue(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
sess = _make_session()
|
||||
sess.add_message("user", "pending")
|
||||
mgr._async_queue.put(sess)
|
||||
|
||||
with patch.object(mgr, "_flush_session") as mock_flush:
|
||||
mgr.flush_all()
|
||||
# Called at least once for the queued item
|
||||
assert mock_flush.call_count >= 1
|
||||
|
||||
def test_flush_all_tolerates_errors(self):
|
||||
mgr = _make_manager(write_frequency="session")
|
||||
sess = _make_session()
|
||||
mgr._cache = {"key": sess}
|
||||
with patch.object(mgr, "_flush_session", side_effect=RuntimeError("oops")):
|
||||
# Should not raise
|
||||
mgr.flush_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# async writer thread lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAsyncWriterThread:
|
||||
def test_thread_started_on_async_mode(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
assert mgr._async_thread is not None
|
||||
assert mgr._async_thread.is_alive()
|
||||
mgr.shutdown()
|
||||
|
||||
def test_no_thread_for_turn_mode(self):
|
||||
mgr = _make_manager(write_frequency="turn")
|
||||
assert mgr._async_thread is None
|
||||
assert mgr._async_queue is None
|
||||
|
||||
def test_shutdown_joins_thread(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
assert mgr._async_thread.is_alive()
|
||||
mgr.shutdown()
|
||||
assert not mgr._async_thread.is_alive()
|
||||
|
||||
def test_async_writer_calls_flush(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
sess = _make_session()
|
||||
sess.add_message("user", "async msg")
|
||||
|
||||
flushed = []
|
||||
original = mgr._flush_session
|
||||
|
||||
def capture(s):
|
||||
flushed.append(s)
|
||||
|
||||
mgr._flush_session = capture
|
||||
mgr._async_queue.put(sess)
|
||||
# Give the daemon thread time to process
|
||||
deadline = time.time() + 2.0
|
||||
while not flushed and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
mgr.shutdown()
|
||||
assert len(flushed) == 1
|
||||
assert flushed[0] is sess
|
||||
|
||||
def test_shutdown_sentinel_stops_loop(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
thread = mgr._async_thread
|
||||
mgr.shutdown()
|
||||
thread.join(timeout=3)
|
||||
assert not thread.is_alive()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# async retry on failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAsyncWriterRetry:
|
||||
def test_retries_once_on_failure(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
sess = _make_session()
|
||||
sess.add_message("user", "msg")
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def flaky_flush(s):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
raise ConnectionError("network blip")
|
||||
# second call succeeds silently
|
||||
|
||||
mgr._flush_session = flaky_flush
|
||||
|
||||
with patch("time.sleep"): # skip the 2s sleep in retry
|
||||
mgr._async_queue.put(sess)
|
||||
deadline = time.time() + 3.0
|
||||
while call_count[0] < 2 and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
mgr.shutdown()
|
||||
assert call_count[0] == 2
|
||||
|
||||
def test_drops_after_two_failures(self):
|
||||
mgr = _make_manager(write_frequency="async")
|
||||
sess = _make_session()
|
||||
sess.add_message("user", "msg")
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def always_fail(s):
|
||||
call_count[0] += 1
|
||||
raise RuntimeError("always broken")
|
||||
|
||||
mgr._flush_session = always_fail
|
||||
|
||||
with patch("time.sleep"):
|
||||
mgr._async_queue.put(sess)
|
||||
deadline = time.time() + 3.0
|
||||
while call_count[0] < 2 and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
mgr.shutdown()
|
||||
# Should have tried exactly twice (initial + one retry) and not crashed
|
||||
assert call_count[0] == 2
|
||||
assert not mgr._async_thread.is_alive()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HonchoClientConfig dataclass defaults for new fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNewConfigFieldDefaults:
|
||||
def test_write_frequency_default(self):
|
||||
cfg = HonchoClientConfig()
|
||||
assert cfg.write_frequency == "async"
|
||||
|
||||
def test_memory_mode_default(self):
|
||||
cfg = HonchoClientConfig()
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
|
||||
def test_write_frequency_set(self):
|
||||
cfg = HonchoClientConfig(write_frequency="turn")
|
||||
assert cfg.write_frequency == "turn"
|
||||
|
||||
def test_memory_mode_set(self):
|
||||
cfg = HonchoClientConfig(memory_mode="honcho")
|
||||
assert cfg.memory_mode == "honcho"
|
||||
|
||||
def test_peer_memory_mode_falls_back_to_global(self):
|
||||
cfg = HonchoClientConfig(memory_mode="honcho")
|
||||
assert cfg.peer_memory_mode("any-peer") == "honcho"
|
||||
|
||||
def test_peer_memory_mode_override(self):
|
||||
cfg = HonchoClientConfig(memory_mode="hybrid", peer_memory_modes={"hermes": "local"})
|
||||
assert cfg.peer_memory_mode("hermes") == "local"
|
||||
assert cfg.peer_memory_mode("other") == "hybrid"
|
||||
|
|
@ -25,7 +25,8 @@ class TestHonchoClientConfigDefaults:
|
|||
assert config.environment == "production"
|
||||
assert config.enabled is False
|
||||
assert config.save_messages is True
|
||||
assert config.session_strategy == "per-directory"
|
||||
assert config.session_strategy == "per-session"
|
||||
assert config.recall_mode == "auto"
|
||||
assert config.session_peer_prefix is False
|
||||
assert config.linked_hosts == []
|
||||
assert config.sessions == {}
|
||||
|
|
@ -134,6 +135,41 @@ class TestFromGlobalConfig:
|
|||
assert config.workspace_id == "root-ws"
|
||||
assert config.ai_peer == "root-ai"
|
||||
|
||||
def test_session_strategy_default_from_global_config(self, tmp_path):
|
||||
"""from_global_config with no sessionStrategy should match dataclass default."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"apiKey": "key"}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.session_strategy == "per-session"
|
||||
|
||||
def test_context_tokens_host_block_wins(self, tmp_path):
|
||||
"""Host block contextTokens should override root."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "key",
|
||||
"contextTokens": 1000,
|
||||
"hosts": {"hermes": {"contextTokens": 2000}},
|
||||
}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.context_tokens == 2000
|
||||
|
||||
def test_recall_mode_from_config(self, tmp_path):
|
||||
"""recallMode is read from config, host block wins."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "key",
|
||||
"recallMode": "tools",
|
||||
"hosts": {"hermes": {"recallMode": "context"}},
|
||||
}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.recall_mode == "context"
|
||||
|
||||
def test_recall_mode_default(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"apiKey": "key"}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.recall_mode == "auto"
|
||||
|
||||
def test_corrupt_config_falls_back_to_env(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("not valid json{{{")
|
||||
|
|
@ -177,6 +213,40 @@ class TestResolveSessionName:
|
|||
# Should use os.getcwd() basename
|
||||
assert result == Path.cwd().name
|
||||
|
||||
def test_per_repo_uses_git_root(self):
|
||||
config = HonchoClientConfig(session_strategy="per-repo")
|
||||
with patch.object(
|
||||
HonchoClientConfig, "_git_repo_name", return_value="hermes-agent"
|
||||
):
|
||||
result = config.resolve_session_name("/home/user/hermes-agent/subdir")
|
||||
assert result == "hermes-agent"
|
||||
|
||||
def test_per_repo_with_peer_prefix(self):
|
||||
config = HonchoClientConfig(
|
||||
session_strategy="per-repo", peer_name="eri", session_peer_prefix=True
|
||||
)
|
||||
with patch.object(
|
||||
HonchoClientConfig, "_git_repo_name", return_value="groudon"
|
||||
):
|
||||
result = config.resolve_session_name("/home/user/groudon/src")
|
||||
assert result == "eri-groudon"
|
||||
|
||||
def test_per_repo_falls_back_to_dirname_outside_git(self):
|
||||
config = HonchoClientConfig(session_strategy="per-repo")
|
||||
with patch.object(
|
||||
HonchoClientConfig, "_git_repo_name", return_value=None
|
||||
):
|
||||
result = config.resolve_session_name("/home/user/not-a-repo")
|
||||
assert result == "not-a-repo"
|
||||
|
||||
def test_per_repo_manual_override_still_wins(self):
|
||||
config = HonchoClientConfig(
|
||||
session_strategy="per-repo",
|
||||
sessions={"/home/user/proj": "custom-session"},
|
||||
)
|
||||
result = config.resolve_session_name("/home/user/proj")
|
||||
assert result == "custom-session"
|
||||
|
||||
|
||||
class TestGetLinkedWorkspaces:
|
||||
def test_resolves_linked_hosts(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue