# 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
1396 lines
58 KiB
Python
1396 lines
58 KiB
Python
"""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
|
||
|