Files
hermes-agent/tests/honcho_plugin/test_client.py
ethernet e8fcb007b9 Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts:
#	AGENTS.md
#	acp_adapter/edit_approval.py
#	acp_adapter/server.py
#	agent/agent_init.py
#	agent/anthropic_adapter.py
#	agent/anthropic_credentials.py
#	agent/auxiliary_client.py
#	agent/azure_identity_adapter.py
#	agent/bedrock_adapter.py
#	agent/browser_registry.py
#	agent/chat_completion_helpers.py
#	agent/coding_context.py
#	agent/context_references.py
#	agent/conversation_loop.py
#	agent/copilot_acp_client.py
#	agent/credits_tracker.py
#	agent/curator.py
#	agent/curator_backup.py
#	agent/deadline.py
#	agent/display.py
#	agent/errors.py
#	agent/estop.py
#	agent/i18n.py
#	agent/image_gen_registry.py
#	agent/image_routing.py
#	agent/learning_graph.py
#	agent/learning_mutations.py
#	agent/lsp/servers.py
#	agent/model_metadata.py
#	agent/models_dev.py
#	agent/monitoring/gateway_health_export.py
#	agent/monitoring/otlp_exporter.py
#	agent/pet/store.py
#	agent/process_bootstrap.py
#	agent/prompt_builder.py
#	agent/proxy_sources/iron_proxy.py
#	agent/secret_sources/_cache.py
#	agent/secret_sources/bitwarden.py
#	agent/secret_sources/registry.py
#	agent/shell_hooks.py
#	agent/skill_bundles.py
#	agent/skill_commands.py
#	agent/skill_utils.py
#	agent/ssl_guard.py
#	agent/ssl_verify.py
#	agent/system_prompt.py
#	agent/terminal_env_registry.py
#	agent/trace_upload.py
#	agent/transcription_registry.py
#	agent/tts_registry.py
#	agent/verify/environment.py
#	agent/vertex_adapter.py
#	agent/video_gen_registry.py
#	agent/web_search_registry.py
#	cli.py
#	cron/jobs.py
#	cron/scheduler.py
#	gateway/agent_cache_pressure.py
#	gateway/cgroup_cleanup.py
#	gateway/channel_directory.py
#	gateway/config.py
#	gateway/control_socket.py
#	gateway/dead_targets.py
#	gateway/drain_control.py
#	gateway/hooks.py
#	gateway/kanban_watchers.py
#	gateway/lifecycle_ledger.py
#	gateway/mirror.py
#	gateway/pairing.py
#	gateway/platform_registry.py
#	gateway/platforms/helpers.py
#	gateway/platforms/weixin.py
#	gateway/readiness.py
#	gateway/restart_loop_guard.py
#	gateway/rich_sent_store.py
#	gateway/run.py
#	gateway/session.py
#	gateway/shutdown_flush.py
#	gateway/shutdown_forensics.py
#	gateway/slash_commands.py
#	gateway/status.py
#	gateway/sticker_cache.py
#	gateway/whatsapp_identity.py
#	hermes_bootstrap.py
#	hermes_cli/_early_recovery.py
#	hermes_cli/_install_repair.py
#	hermes_cli/_startup_fast.py
#	hermes_cli/_subprocess_compat.py
#	hermes_cli/agent_plugins.py
#	hermes_cli/auth.py
#	hermes_cli/backup.py
#	hermes_cli/banner.py
#	hermes_cli/browser_connect.py
#	hermes_cli/build_info.py
#	hermes_cli/cli_agent_setup_mixin.py
#	hermes_cli/cli_commands_mixin.py
#	hermes_cli/codex_models.py
#	hermes_cli/config.py
#	hermes_cli/config_defaults.py
#	hermes_cli/config_migrations.py
#	hermes_cli/container_boot.py
#	hermes_cli/dashboard_auth/registry.py
#	hermes_cli/debug.py
#	hermes_cli/dep_ensure.py
#	hermes_cli/doctor.py
#	hermes_cli/doctor_live.py
#	hermes_cli/dump.py
#	hermes_cli/env_loader.py
#	hermes_cli/foreign_sessions.py
#	hermes_cli/gateway.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/gui_uninstall.py
#	hermes_cli/image_provenance.py
#	hermes_cli/install_identity.py
#	hermes_cli/kanban.py
#	hermes_cli/kanban_db.py
#	hermes_cli/linux_desktop_entry.py
#	hermes_cli/local_runtime/binaries.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/local_runtime/growth.py
#	hermes_cli/local_runtime/supervisor.py
#	hermes_cli/logs.py
#	hermes_cli/macos_tcc_anchor.py
#	hermes_cli/main.py
#	hermes_cli/memory_setup.py
#	hermes_cli/model_catalog.py
#	hermes_cli/models.py
#	hermes_cli/nous_subscription.py
#	hermes_cli/npm_engine.py
#	hermes_cli/plugin_index.py
#	hermes_cli/plugins.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/profile_distribution.py
#	hermes_cli/profiles.py
#	hermes_cli/prompt_size.py
#	hermes_cli/psutil_android.py
#	hermes_cli/runtime_repair.py
#	hermes_cli/security_advisories.py
#	hermes_cli/security_audit.py
#	hermes_cli/security_audit_startup.py
#	hermes_cli/service_manager.py
#	hermes_cli/session_export_md.py
#	hermes_cli/setup.py
#	hermes_cli/skills_hub.py
#	hermes_cli/slack_cli.py
#	hermes_cli/status.py
#	hermes_cli/subcommands/gateway.py
#	hermes_cli/subcommands/uninstall.py
#	hermes_cli/tools_config.py
#	hermes_cli/uninstall.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_contract.py
#	hermes_cli/update_inventory.py
#	hermes_cli/update_lock.py
#	hermes_cli/update_receipt.py
#	hermes_cli/urllib_security.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_routers/profiles.py
#	hermes_cli/web_routers/skills.py
#	hermes_cli/web_server.py
#	hermes_constants.py
#	hermes_state.py
#	plugins/disk-cleanup/__init__.py
#	plugins/disk-cleanup/disk_cleanup.py
#	plugins/google_meet/node/registry.py
#	plugins/google_meet/node/server.py
#	plugins/google_meet/process_manager.py
#	plugins/google_meet/realtime/openai_client.py
#	plugins/hermes-achievements/dashboard/plugin_api.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/honcho/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/honcho/client.py
#	plugins/memory/honcho/oauth.py
#	plugins/memory/honcho/session.py
#	plugins/memory/mem0/__init__.py
#	plugins/memory/mem0/_setup.py
#	plugins/memory/openviking/__init__.py
#	plugins/memory/retaindb/__init__.py
#	plugins/memory/supermemory/__init__.py
#	plugins/platforms/a2a/protocol.py
#	plugins/platforms/dingtalk/adapter.py
#	plugins/platforms/discord/adapter.py
#	plugins/platforms/feishu/adapter.py
#	plugins/platforms/google_chat/adapter.py
#	plugins/platforms/matrix/adapter.py
#	plugins/platforms/photon/adapter.py
#	plugins/platforms/photon/auth.py
#	plugins/platforms/photon/cli.py
#	plugins/platforms/slack/adapter.py
#	plugins/platforms/teams/adapter.py
#	plugins/platforms/telegram/adapter.py
#	plugins/platforms/wecom/callback_adapter.py
#	plugins/platforms/whatsapp/adapter.py
#	plugins/teams_pipeline/store.py
#	plugins/video_gen/fal/__init__.py
#	plugins/web/ddgs/provider.py
#	plugins/web/exa/provider.py
#	plugins/web/firecrawl/provider.py
#	plugins/web/parallel/provider.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_cmd_update_apt.py
#	tests/hermes_cli/test_dashboard_unified_launch.py
#	tests/hermes_cli/test_dep_ensure.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_live.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_kanban_boards.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_memory_setup_provider_arg.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_pip_install_detection.py
#	tests/hermes_cli/test_profile_export_credentials.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_tui_npm_install.py
#	tests/hermes_cli/test_update_fleet_restart_pending.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/tools/test_browser_chromium_autoinstall.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_lightpanda.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_open_timeout.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_suspect_recycle.py
#	tests/tools/test_find_shell.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_macos_protected_search.py
#	tests/tui_gateway/test_compute_host.py
#	tools/approval.py
#	tools/blueprints.py
#	tools/bot_mode_dm.py
#	tools/bot_mode_probe.py
#	tools/bot_relay.py
#	tools/browser_tool.py
#	tools/browser_use_cli.py
#	tools/checkpoint_manager.py
#	tools/code_execution_tool.py
#	tools/code_kernel.py
#	tools/computer_use/cua_backend.py
#	tools/cronjob_tools.py
#	tools/discord_tool.py
#	tools/environments/base.py
#	tools/environments/daytona.py
#	tools/environments/local.py
#	tools/environments/modal.py
#	tools/environments/vercel_sandbox.py
#	tools/fal_common.py
#	tools/file_operations.py
#	tools/lazy_deps.py
#	tools/mcp_tool.py
#	tools/neutts_synth.py
#	tools/process_registry.py
#	tools/read_extract.py
#	tools/registry.py
#	tools/skill_ledger.py
#	tools/skill_linter.py
#	tools/skill_manager_tool.py
#	tools/skill_usage.py
#	tools/skills_ast_audit.py
#	tools/skills_guard.py
#	tools/skills_hub.py
#	tools/skills_sync.py
#	tools/skills_sync_client.py
#	tools/skills_tool.py
#	tools/terminal_scope.py
#	tools/terminal_tool.py
#	tools/tirith_security.py
#	tools/transcription_tools.py
#	tools/tts_tool.py
#	tools/vision_tools.py
#	tools/voice_mode.py
#	tools/wake_word.py
#	tools/web_result_cache.py
#	tools/website_policy.py
#	tools/working_diff.py
#	tools/write_approval.py
#	tui_gateway/entry.py
#	tui_gateway/methods_tools.py
#	tui_gateway/server.py
2026-09-04 13:03:39 -04:00

769 lines
30 KiB
Python

"""Tests for plugins/memory/honcho/client.py — Honcho client configuration."""
import importlib.util
import json
import os
import sys
import types
from pathlib import Path
from unittest.mock import patch, MagicMock
from hermes_cli.profiles import _get_default_hermes_home
import pytest
from plugins.memory.honcho.client import (
HonchoClientConfig,
get_honcho_client,
profile_host_key,
reset_honcho_client,
resolve_active_host,
resolve_config_path,
resolve_global_config_path,
)
import os as _os
_SYS_ENV = {k: _os.environ[k] for k in ("SYSTEMROOT", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "HOME") if k in _os.environ}
class TestHonchoClientConfigDefaults:
def test_default_values(self):
config = HonchoClientConfig()
assert config.host == "hermes"
assert config.workspace_id == "hermes"
assert config.api_key is None
assert config.environment == "production"
assert config.timeout is None
assert config.enabled is False
assert config.save_messages is True
assert config.session_strategy == "per-directory"
assert config.recall_mode == "hybrid"
assert config.session_peer_prefix is False
assert config.sessions == {}
class TestFromEnv:
def test_reads_api_key_from_env(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "test-key-123"}):
config = HonchoClientConfig.from_env()
assert config.api_key == "test-key-123"
assert config.enabled is True
def test_defaults_without_env(self):
with patch.dict(os.environ, _SYS_ENV, clear=True):
# Remove HONCHO_API_KEY if it exists
os.environ.pop("HONCHO_API_KEY", None)
os.environ.pop("HONCHO_ENVIRONMENT", None)
config = HonchoClientConfig.from_env()
assert config.api_key is None
assert config.environment == "production"
def test_enabled_without_api_key_when_base_url_set(self):
"""base_url alone (no API key) is sufficient to enable a local instance."""
with patch.dict(os.environ, {"HONCHO_BASE_URL": "http://localhost:8000"}, clear=False):
os.environ.pop("HONCHO_API_KEY", None)
config = HonchoClientConfig.from_env()
assert config.api_key is None
assert config.base_url == "http://localhost:8000"
assert config.enabled is True
def test_honcho_url_env_var_is_honored(self):
"""HONCHO_URL is the SDK's own env var; from_env() accepts it too."""
with patch.dict(os.environ, {"HONCHO_URL": "http://localhost:8000"}, clear=False):
os.environ.pop("HONCHO_API_KEY", None)
os.environ.pop("HONCHO_BASE_URL", None)
config = HonchoClientConfig.from_env()
assert config.base_url == "http://localhost:8000"
assert config.enabled is True
def test_honcho_base_url_wins_over_honcho_url(self):
with patch.dict(
os.environ,
{
"HONCHO_BASE_URL": "http://localhost:8000",
"HONCHO_URL": "http://localhost:9999",
},
clear=False,
):
config = HonchoClientConfig.from_env()
assert config.base_url == "http://localhost:8000"
class TestFromGlobalConfig:
def test_missing_config_falls_back_to_env(self, tmp_path):
with patch.dict(os.environ, _SYS_ENV, clear=True):
config = HonchoClientConfig.from_global_config(
config_path=tmp_path / "nonexistent.json"
)
# Should fall back to from_env
assert config.enabled is False
assert config.api_key is None
def test_missing_config_still_reads_honcho_url(self, tmp_path):
"""The env fallback path must honor HONCHO_URL, not just HONCHO_BASE_URL.
from_global_config() returns from_env() when the config file is
absent, so a fallback that only from_global_config() understood
would silently do nothing for users with no ~/.honcho/config.json.
"""
with patch.dict(os.environ, {**_SYS_ENV, "HONCHO_URL": "http://localhost:8000"}, clear=True):
config = HonchoClientConfig.from_global_config(
config_path=tmp_path / "nonexistent.json"
)
assert config.base_url == "http://localhost:8000"
assert config.enabled is True
def test_base_url_from_sdk_native_endpoint_block(self, tmp_path):
"""endpoint.baseUrl is the SDK-native spelling Claude Desktop writes."""
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"apiKey": "key",
"endpoint": {"baseUrl": "http://localhost:8000"},
}))
with patch.dict(os.environ, _SYS_ENV, clear=True):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.base_url == "http://localhost:8000"
def test_endpoint_base_url_wins_over_top_level_and_env(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"endpoint": {"baseUrl": "http://localhost:8000"},
"baseUrl": "http://localhost:9001",
"base_url": "http://localhost:9002",
}))
with patch.dict(os.environ, {**_SYS_ENV, "HONCHO_BASE_URL": "http://localhost:9003"}, clear=True):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.base_url == "http://localhost:8000"
def test_endpoint_block_non_dict_is_ignored(self, tmp_path):
"""A malformed endpoint value falls through instead of crashing."""
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"endpoint": "http://localhost:8000",
"baseUrl": "http://localhost:9001",
}))
with patch.dict(os.environ, _SYS_ENV, clear=True):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.base_url == "http://localhost:9001"
def test_host_block_overrides_root(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"apiKey": "key",
"workspace": "root-ws",
"aiPeer": "root-ai",
"hosts": {
"hermes": {
"workspace": "host-ws",
"aiPeer": "host-ai",
}
}
}))
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.workspace_id == "host-ws"
assert config.ai_peer == "host-ai"
def test_context_tokens_explicit_sets_cap(self, tmp_path):
"""Explicit contextTokens in config sets the cap."""
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"apiKey": "***", "contextTokens": 1200}))
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.context_tokens == 1200
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_corrupt_config_falls_back_to_env(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text("not valid json{{{")
config = HonchoClientConfig.from_global_config(config_path=config_file)
# Should fall back to from_env without crashing
assert isinstance(config, HonchoClientConfig)
def test_base_url_host_block_overrides_root_and_env(self, tmp_path):
"""Host-specific baseUrl should win for self-hosted Honcho deployments."""
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({
"baseUrl": "http://root:9000",
"hosts": {"hermes": {"baseUrl": "http://host-block:9001"}},
}))
with patch.dict(os.environ, {"HONCHO_BASE_URL": "http://env:8000"}, clear=False):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.base_url == "http://host-block:9001"
def test_base_url_full_precedence_chain(self, tmp_path):
"""Invariant: host block > endpoint.baseUrl (SDK-native) > flat root
> HONCHO_BASE_URL > HONCHO_URL. Pins the composed order of #14489
(host block) and #43803 (endpoint block + HONCHO_URL)."""
config_file = tmp_path / "config.json"
layers = {
"hosts": {"hermes": {"baseUrl": "http://host:1"}},
"endpoint": {"baseUrl": "http://endpoint:2"},
"baseUrl": "http://flat:3",
}
env = {"HONCHO_BASE_URL": "http://envbase:4", "HONCHO_URL": "http://envurl:5"}
expected = [
"http://host:1", # full stack -> host block wins
"http://endpoint:2", # drop host block -> SDK-native endpoint
"http://flat:3", # drop endpoint -> flat root key
"http://envbase:4", # empty file -> HONCHO_BASE_URL
"http://envurl:5", # drop HONCHO_BASE_URL -> HONCHO_URL
]
for i, want in enumerate(expected):
cfg_dict = dict(layers)
if i >= 1:
cfg_dict.pop("hosts")
if i >= 2:
cfg_dict.pop("endpoint")
if i >= 3:
cfg_dict.pop("baseUrl")
env_dict = dict(env)
if i >= 4:
env_dict.pop("HONCHO_BASE_URL")
config_file.write_text(json.dumps(cfg_dict))
with patch.dict(os.environ, {**_SYS_ENV, **env_dict}, clear=True):
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.base_url == want, f"layer {i}: got {config.base_url!r}, want {want!r}"
class TestResolveSessionName:
def test_manual_override(self):
config = HonchoClientConfig(sessions={"/home/user/proj": "custom-session"})
assert config.resolve_session_name("/home/user/proj") == "custom-session"
def test_derive_from_dirname(self):
config = HonchoClientConfig()
result = config.resolve_session_name("/home/user/my-project")
assert result == "my-project"
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"
class TestResolveConfigPath:
def test_prefers_hermes_home_when_exists(self, tmp_path):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
local_cfg = hermes_home / "honcho.json"
local_cfg.write_text('{"apiKey": "local"}')
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
result = resolve_config_path()
assert result == local_cfg
def test_falls_back_to_default_profile_when_no_local(self, tmp_path, monkeypatch):
# Profile mode: HERMES_HOME points at ~/.hermes/profiles/<name>, so
# _get_default_hermes_home() must resolve back to ~/.hermes — that's
# the bug the HOME-anchored helper fixes (vs. blindly using Path.home()).
fake_home = tmp_path / "fakehome"
fake_home.mkdir()
default_home = fake_home / ".hermes"
profile_home = default_home / "profiles" / "work"
profile_home.mkdir(parents=True)
default_cfg = default_home / "honcho.json"
default_cfg.write_text('{"apiKey": "default-key"}')
monkeypatch.setattr(Path, "home", lambda: fake_home)
monkeypatch.setenv("HERMES_HOME", str(profile_home))
result = resolve_config_path()
assert _get_default_hermes_home() == default_home
assert result == default_cfg
class TestResolveActiveHost:
def test_profile_host_key_uses_honcho_safe_separator(self):
assert profile_host_key("coder") == "hermes_coder"
assert profile_host_key("default") == "hermes"
def test_explicit_env_var_wins(self):
with patch.dict(os.environ, {"HERMES_HONCHO_HOST": "hermes.coder"}):
assert resolve_active_host() == "hermes.coder"
def test_profiles_import_failure_falls_back(self):
import sys
with patch.dict(os.environ, {}, clear=False), patch(
"plugins.memory.honcho.client.resolve_config_path",
return_value=Path("/nonexistent/test-honcho-config.json"),
):
os.environ.pop("HERMES_HONCHO_HOST", None)
# Temporarily remove hermes_cli.profiles to simulate import failure
saved = sys.modules.get("hermes_cli.profiles")
sys.modules["hermes_cli.profiles"] = None # type: ignore
try:
assert resolve_active_host() == "hermes"
finally:
if saved is not None:
sys.modules["hermes_cli.profiles"] = saved
else:
sys.modules.pop("hermes_cli.profiles", None)
class TestProfileScopedConfig:
def test_from_env_uses_profile_host(self):
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
config = HonchoClientConfig.from_env(host="hermes_coder")
assert config.host == "hermes_coder"
assert config.workspace_id == "hermes" # shared workspace
assert config.ai_peer == "hermes_coder"
class TestObservationModeMigration:
"""Existing configs without explicit observationMode keep 'unified' default."""
def test_existing_config_defaults_to_unified(self, tmp_path):
"""Config with host block but no observationMode → 'unified' (old default)."""
cfg_file = tmp_path / "config.json"
cfg_file.write_text(json.dumps({
"apiKey": "k",
"hosts": {"hermes": {"enabled": True, "aiPeer": "hermes"}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
assert cfg.observation_mode == "unified"
def test_new_config_defaults_to_directional(self, tmp_path):
"""Config with no host block and no credentials → 'directional' (new default)."""
cfg_file = tmp_path / "config.json"
cfg_file.write_text(json.dumps({}))
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
assert cfg.observation_mode == "directional"
def test_granular_observation_overrides_preset(self, tmp_path):
"""Explicit observation object overrides both preset and migration default."""
cfg_file = tmp_path / "config.json"
cfg_file.write_text(json.dumps({
"apiKey": "k",
"hosts": {"hermes": {
"enabled": True,
"observation": {
"user": {"observeMe": True, "observeOthers": False},
"ai": {"observeMe": False, "observeOthers": True},
},
}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
# observation_mode falls back to "unified" (migration), but
# granular booleans from the observation object win
assert cfg.user_observe_me is True
assert cfg.user_observe_others is False
assert cfg.ai_observe_me is False
assert cfg.ai_observe_others is True
class TestGetHonchoClient:
def teardown_method(self):
reset_honcho_client()
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_dot_form_legacy_host_key_keeps_local_api_key(self):
"""Regression for #37436: a legacy dot-form host block (hermes.work)
must be found by the local-auth check. Before the _host_block fallback,
the direct dict lookup missed it, the stored apiKey was dropped for the
'local' placeholder, and every write 401'd silently."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="explicit-local-key",
base_url="http://localhost:8000",
host="hermes_work",
workspace_id="hermes",
raw={"hosts": {"hermes.work": {"apiKey": "explicit-local-key"}}},
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho:
get_honcho_client(cfg)
assert mock_honcho.call_args.kwargs["api_key"] == "explicit-local-key"
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_without_host_key_uses_placeholder(self):
"""Without an explicit apiKey anywhere in honcho.json, a local
base_url gets the SDK's non-empty placeholder instead of the (likely
cloud, env-sourced) resolved key."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="cloud-root-key",
base_url="http://localhost:8000",
host="hermes",
workspace_id="hermes",
raw={},
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho:
get_honcho_client(cfg)
assert mock_honcho.call_args.kwargs["api_key"] == "local"
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_honors_top_level_api_key(self):
"""Regression for #36098 issue 2: a top-level apiKey in honcho.json is
explicit user intent and must be honored for local base_urls (AUTH_USE_AUTH
self-hosts). Previously only a host-block apiKey escaped the 'local'
placeholder, so the top-level key was dropped and every request 401'd."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="explicit-top-level-key",
base_url="http://localhost:8000",
host="hermes",
workspace_id="hermes",
raw={"apiKey": "explicit-top-level-key"},
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho:
get_honcho_client(cfg)
assert mock_honcho.call_args.kwargs["api_key"] == "explicit-top-level-key"
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_passes_timeout_from_config(self):
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="test-key",
timeout=91.0,
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho:
client = get_honcho_client(cfg)
assert client is fake_honcho
mock_honcho.assert_called_once()
assert mock_honcho.call_args.kwargs["timeout"] == 91.0
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_timeout_change_triggers_client_rebuild(self):
"""Changing timeout config must rebuild the cached client."""
from hermes_constants import get_hermes_home
cfg_yaml = get_hermes_home() / "config.yaml"
cfg_yaml.write_text("honcho:\n timeout: 30\n")
fake_honcho_1 = MagicMock(name="Honcho_v1")
fake_honcho_2 = MagicMock(name="Honcho_v2")
cfg = HonchoClientConfig(
api_key="test-key",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho_1) as mock_h1:
client1 = get_honcho_client(cfg)
assert client1 is fake_honcho_1
assert mock_h1.call_args.kwargs["timeout"] == 30.0
# Same config — should return cached client (no rebuild)
with patch("honcho.Honcho", return_value=fake_honcho_2) as mock_h2:
client2 = get_honcho_client(cfg)
assert client2 is fake_honcho_1 # still cached
mock_h2.assert_not_called()
# Changed timeout — must rebuild
cfg_yaml.write_text("honcho:\n timeout: 300\n")
st = cfg_yaml.stat()
os.utime(cfg_yaml, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
with patch("honcho.Honcho", return_value=fake_honcho_2) as mock_h3:
client3 = get_honcho_client(cfg)
assert client3 is fake_honcho_2 # rebuilt
mock_h3.assert_called_once()
assert mock_h3.call_args.kwargs["timeout"] == 300.0
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_managed_config_timeout_does_not_thrash_singleton(self, tmp_path, monkeypatch):
"""A managed-scope honcho.timeout with no user config.yaml must be seen
by the staleness check (stable reuse), and a managed edit must trigger
a rebuild. Regression for a memo that keyed only on the user file."""
managed_dir = tmp_path / "managed"
managed_dir.mkdir()
managed_cfg = managed_dir / "config.yaml"
managed_cfg.write_text("honcho:\n timeout: 88\n")
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed_dir))
fake_honcho_1 = MagicMock(name="Honcho_v1")
fake_honcho_2 = MagicMock(name="Honcho_v2")
cfg = HonchoClientConfig(
api_key="test-key",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho_1) as mock_h1:
client1 = get_honcho_client(cfg)
client2 = get_honcho_client(cfg)
assert client1 is fake_honcho_1
assert client2 is fake_honcho_1
assert mock_h1.call_count == 1
assert mock_h1.call_args.kwargs["timeout"] == 88.0
# A managed-timeout edit is detected (same-size write, so bump mtime).
managed_cfg.write_text("honcho:\n timeout: 99\n")
st = managed_cfg.stat()
os.utime(managed_cfg, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
with patch("honcho.Honcho", return_value=fake_honcho_2) as mock_h2:
client3 = get_honcho_client(cfg)
assert client3 is fake_honcho_2
mock_h2.assert_called_once()
assert mock_h2.call_args.kwargs["timeout"] == 99.0
class TestResolveSessionNameGatewayKey:
"""Regression tests for gateway_session_key priority in resolve_session_name.
Ensures gateway platforms get stable per-chat Honcho sessions even when
sessionStrategy=per-session would otherwise create ephemeral sessions.
Regression: plugin refactor 924bc67e dropped gateway key plumbing.
"""
def test_gateway_key_overrides_per_session_strategy(self):
"""gateway_session_key must win over per-session session_id."""
config = HonchoClientConfig(session_strategy="per-session")
result = config.resolve_session_name(
session_id="20260412_171002_69bb38",
gateway_session_key="agent:main:telegram:dm:8439114563",
)
assert result == "agent-main-telegram-dm-8439114563"
def test_gateway_key_sanitizes_special_chars(self):
"""Colons and other non-alphanumeric chars are replaced with hyphens."""
config = HonchoClientConfig()
result = config.resolve_session_name(
gateway_session_key="agent:main:telegram:dm:8439114563",
)
assert result == "agent-main-telegram-dm-8439114563"
assert ":" not in result
class TestResolveSessionNameLengthLimit:
"""Regression tests for Honcho's 100-char session ID limit (issue #13868).
Long gateway session keys (Matrix room+event IDs, Telegram supergroup
reply chains, Slack thread IDs with long workspace prefixes) can overflow
Honcho's 100-char session_id limit after sanitization. Before this fix,
every Honcho API call for those sessions 400'd with "session_id too long".
"""
HONCHO_MAX = 100
def test_short_gateway_key_unchanged(self):
"""Short keys must not get a hash suffix appended."""
config = HonchoClientConfig()
result = config.resolve_session_name(
gateway_session_key="agent:main:telegram:dm:8439114563",
)
# Unchanged fast-path: sanitize only, no truncation, no hash suffix.
assert result == "agent-main-telegram-dm-8439114563"
assert len(result) <= self.HONCHO_MAX
def test_long_gateway_key_truncated_to_limit(self):
"""An over-limit sanitized key must truncate to exactly 100 chars."""
key = "!roomid:matrix.example.org|" + "$event_" + ("a" * 300)
config = HonchoClientConfig()
result = config.resolve_session_name(gateway_session_key=key)
assert result is not None
assert len(result) == self.HONCHO_MAX
def test_distinct_long_keys_do_not_collide(self):
"""Two long keys sharing a prefix must produce different truncated IDs."""
prefix = "matrix:!room:example.org|" + "a" * 200
key_a = prefix + "-suffix-alpha"
key_b = prefix + "-suffix-beta"
config = HonchoClientConfig()
result_a = config.resolve_session_name(gateway_session_key=key_a)
result_b = config.resolve_session_name(gateway_session_key=key_b)
assert result_a != result_b
assert len(result_a) == self.HONCHO_MAX
assert len(result_b) == self.HONCHO_MAX
class TestDialecticDepthParsing:
"""Tests for _parse_dialectic_depth and _parse_dialectic_depth_levels."""
def test_depth_clamped_high(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"apiKey": "***", "dialecticDepth": 10}))
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.dialectic_depth == 3
class TestGetHonchoClientBaseUrlDoublePrefixFix:
"""Regression tests for #20688 — Honcho SDK double-prefixing of /v3 for
self-hosted instances where base_url already contains a version path."""
def teardown_method(self):
reset_honcho_client()
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
def test_local_base_url_with_v3_suffix_stripped(self):
"""base_url 'http://localhost:38000/v3' must become 'http://localhost:38000'
before passing to the Honcho SDK to avoid double '/v3/v3' prefixing."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key=None,
base_url="http://localhost:38000/v3",
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == "http://localhost:38000", (
f"Expected 'http://localhost:38000', got {passed_base_url!r}"
)
def test_lan_default_host_empty_key_uses_local_placeholder(self, tmp_path):
"""Regression for #61661: setup-style root baseUrl + defaultHost + LAN IP
must not pass an empty/None api_key to the SDK for a no-auth local server."""
config_file = tmp_path / "honcho.json"
config_file.write_text(json.dumps({
"defaultHost": "local",
"baseUrl": "http://192.168.2.112:8000",
"hosts": {
"local": {
"workspace": "local-ws",
"aiPeer": "local-ai",
"apiKey": "",
},
},
}))
with patch.dict(os.environ, _SYS_ENV, clear=True), \
patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
patch("plugins.memory.honcho.client.resolve_config_path", return_value=config_file):
cfg = HonchoClientConfig.from_global_config(config_path=config_file)
assert cfg.host == "local"
assert cfg.workspace_id == "local-ws"
assert cfg.ai_peer == "local-ai"
assert cfg.api_key is None
assert cfg.base_url == "http://192.168.2.112:8000"
fake_honcho = MagicMock(name="Honcho")
mock_honcho = MagicMock(return_value=fake_honcho)
fake_honcho_module = types.SimpleNamespace(Honcho=mock_honcho)
with patch.dict(sys.modules, {"honcho": fake_honcho_module}), \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
assert mock_honcho.call_args.kwargs["api_key"] == "local"
assert mock_honcho.call_args.kwargs["base_url"] == "http://192.168.2.112:8000"
@pytest.mark.skipif(
not importlib.util.find_spec("honcho"),
reason="honcho SDK not installed"
)
@pytest.mark.parametrize(
"raw_url, expected",
[
# LAN IP self-host
("http://10.0.0.5:8000/v3", "http://10.0.0.5:8000"),
("http://192.168.1.20:38000/v3/", "http://192.168.1.20:38000"),
# Tailscale / custom-domain self-host
("https://honcho.my.ts.net/v3", "https://honcho.my.ts.net"),
("https://honcho.lab.internal/v3", "https://honcho.lab.internal"),
("https://honcho.fly.dev/v3", "https://honcho.fly.dev"),
# higher version segments are also stripped
("https://honcho.lab.internal/v12", "https://honcho.lab.internal"),
# self-host without a version segment is left unchanged
("https://honcho.my.ts.net", "https://honcho.my.ts.net"),
("http://10.0.0.5:8000", "http://10.0.0.5:8000"),
],
)
def test_self_hosted_base_url_version_stripped(self, raw_url, expected):
"""Non-loopback self-hosted instances (LAN IPs, Tailscale, custom
domains) must get the same version-segment stripping as localhost.
Regression for #20688 recurring on any non-loopback self-host."""
fake_honcho = MagicMock(name="Honcho")
cfg = HonchoClientConfig(
api_key="self-host-key",
base_url=raw_url,
workspace_id="hermes",
environment="production",
)
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
patch("hermes_cli.config.load_config", return_value={}):
get_honcho_client(cfg)
mock_honcho.assert_called_once()
passed_base_url = mock_honcho.call_args.kwargs.get("base_url")
assert passed_base_url == expected, (
f"Expected {expected!r}, got {passed_base_url!r}"
)