Files
hermes-agent/tests/honcho_plugin/test_session.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

1396 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for plugins/memory/honcho/session.py — HonchoSession and helpers."""
import json
import os
import sys
import threading
import time
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
# ---------------------------------------------------------------------------
# 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_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."""
@staticmethod
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_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_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."""
@staticmethod
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_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_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)
# ---------------------------------------------------------------------------
# Trivial-prompt heuristic + dialectic cadence silent-failure guards
# ---------------------------------------------------------------------------
class TestTrivialPromptHeuristic:
"""Trivial prompts ('ok', 'y', slash commands) must short-circuit injection."""
@staticmethod
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_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."""
@staticmethod
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_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 TestDialecticLiveness:
"""Liveness + observability: stale-thread recovery, stale-result discard,
empty-streak backoff, and the snapshot method used for diagnostics."""
@staticmethod
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."""
@staticmethod
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."""
@staticmethod
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:
@pytest.mark.parametrize("raw, headings", [
({}, ["## Session Summary", "## User Representation", "## User Peer Card",
"## AI Self-Representation", "## AI Identity Card"]),
({"injection": {"sessionStart": []}}, []),
({"injection": {"sessionStart": ["aiCard", "summary"]}}, ["## Session Summary", "## AI Identity Card"]),
({"injection": {"sessionStart": ["summary"]}, "hosts": {"hermes": {"injection": {"sessionStart": ["peerCard"]}}}},
["## User Peer Card"]),
], ids=["unset-renders-all-in-fixed-order", "empty-list-injects-nothing", "pin-keeps-table-order", "host-block-beats-root"])
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
@pytest.mark.parametrize("submitted, headings", [
('{"sessionStart": ["peerCard"]}', ["## User Peer Card"]),
('{"sessionStart": []}', []),
("", ["## Session Summary", "## User Representation", "## User Peer Card",
"## AI Self-Representation", "## AI Identity Card"]),
], ids=["pin", "empty-list", "blank-clears-the-pin"])
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:
@pytest.fixture(autouse=True)
def _no_logging_env(self, monkeypatch):
monkeypatch.delenv("HONCHO_LOGGING", raising=False)
monkeypatch.delenv("HONCHO_INJECTION_LOG", raising=False)
@pytest.mark.parametrize("raw, env", [
({}, None),
({"logging": True, "hosts": {"hermes": {"logging": False}}}, None),
*[({"logging": value}, None) for value in ("false", "0", "no", "off", "")],
({}, "off"),
])
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
@pytest.mark.parametrize("value", [True, "true", "1", "yes", "on"])
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")
@pytest.mark.platforms("posix") # POSIX file modes
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