From 44ba32a5650ebc1d6b44183ec2ad70670e48d5ff Mon Sep 17 00:00:00 2001 From: outpoints <12377381+outpoints@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:05:23 -0700 Subject: [PATCH] fix(honcho): preserve deferred routing invariants Thread logical session cwd through deferred Desktop/TUI builds, normalize absent cwd during construction, and share title provenance constants between SessionDB and Honcho. (cherry picked from commit 2693f4f27c776ac819d92c9b52e8a03ad2a985d8) --- agent/agent_init.py | 2 +- hermes_state.py | 10 +++- hermes_state_common.py | 6 ++ plugins/memory/honcho/client.py | 4 +- tests/agent/test_memory_user_id.py | 44 +++++++++++++- tests/honcho_plugin/test_client.py | 16 +++++ .../test_deferred_agent_build_cwd.py | 59 +++++++++++++++++++ tests/tui_gateway/test_tui_gateway_server.py | 4 +- 8 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 tests/tui_gateway/test_deferred_agent_build_cwd.py diff --git a/agent/agent_init.py b/agent/agent_init.py index ad1a8ceb21..fa82e4d5da 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -2236,7 +2236,7 @@ def init_agent( setattr(agent, _name, _params[_name]) for _name in _GATEWAY_IDENTITY_PARAMS: setattr(agent, f"_{_name}", _params[_name]) - agent.session_cwd = cwd + agent.session_cwd = cwd or None # Shared iteration budget: parent creates, children inherit. agent.iteration_budget = iteration_budget or IterationBudget(max_iterations) # CLI replaces this with _cprint so raw ANSI status lines go through prompt_toolkit's diff --git a/hermes_state.py b/hermes_state.py index 598a3ff1ec..6040c29948 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -27,7 +27,11 @@ from pathlib import Path from hermes_constants import get_hermes_home, mkdir_under_hermes_home from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast -from hermes_state_common import escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity +from hermes_state_common import ( + TITLE_SOURCE_DERIVED as _TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM as _TITLE_SOURCE_LLM, + TITLE_SOURCE_USER as _TITLE_SOURCE_USER, + escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity, +) from hermes_state_holders import read_only_db_uri from hermes_state_errors import ( _DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY, @@ -1493,7 +1497,9 @@ class SessionDB( # Title provenance, lowest to highest authority: auto-titling may only replace a # strictly lower-authority title (``derived`` -> ``llm`` once; never a user-typed name). - TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM, TITLE_SOURCE_USER = "derived", "llm", "user" + TITLE_SOURCE_DERIVED = _TITLE_SOURCE_DERIVED + TITLE_SOURCE_LLM = _TITLE_SOURCE_LLM + TITLE_SOURCE_USER = _TITLE_SOURCE_USER _TITLE_SOURCE_RANK = {TITLE_SOURCE_DERIVED: 0, TITLE_SOURCE_LLM: 1, TITLE_SOURCE_USER: 2} # Bot Mode's canonical chat is resolved by exact-title lookup: the title IS the identity, diff --git a/hermes_state_common.py b/hermes_state_common.py index ca3722696e..8ac4ce37e9 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -15,6 +15,12 @@ from agent.context_compressor import (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX, _ME _MERGED_SUMMARY_DELIMITER, _SUMMARY_END_MARKER) +# Persisted title provenance: automatic display labels are not user-selected identities. +TITLE_SOURCE_DERIVED = "derived" +TITLE_SOURCE_LLM = "llm" +TITLE_SOURCE_USER = "user" + + # Session preview = head of the first user message (shown when a session has no title). A /skill invocation # embeds the whole skill body, so scaffolded rows take a wider excerpt (whole message under budget, else head + # tail where the typed instruction lands) and ``_shape_preview`` recovers ``/work — fix ...`` from it. diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 401b7ed18b..3365351679 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -30,6 +30,7 @@ from agent.memory_provider import spawn_context_thread as _spawn_context_thread from agent.secret_scope import get_secret from hermes_cli.profiles import _get_default_hermes_home from hermes_constants import get_hermes_home +from hermes_state_common import TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM from plugins.memory.honcho.client_cache import ( _DEFAULT_HTTP_TIMEOUT, _client_cache_key, _client_slots, _client_slots_lock, @@ -42,6 +43,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) HOST = "hermes" +_AUTOMATIC_SESSION_TITLE_SOURCES = frozenset({TITLE_SOURCE_DERIVED, TITLE_SOURCE_LLM}) def _sanitize_url(url: str | None) -> str | None: @@ -526,7 +528,7 @@ class HonchoClientConfig: return manual # Absent provenance retains the legacy explicit-title override. Generated # display titles must not change a strategy-selected memory identity. - if session_title and session_title_source not in {"derived", "llm"} and _slug(session_title): + if session_title and session_title_source not in _AUTOMATIC_SESSION_TITLE_SOURCES and _slug(session_title): return self._with_peer_prefix(_slug(session_title)) if self.session_strategy == "per-repo": return self._with_peer_prefix(self._git_repo_name(cwd) or Path(cwd).name) diff --git a/tests/agent/test_memory_user_id.py b/tests/agent/test_memory_user_id.py index 82b6b0215d..3c6b8e66b4 100644 --- a/tests/agent/test_memory_user_id.py +++ b/tests/agent/test_memory_user_id.py @@ -8,6 +8,8 @@ import json import os from unittest.mock import MagicMock, patch +import pytest + from agent.memory_provider import MemoryProvider from agent.memory_manager import MemoryManager @@ -108,13 +110,13 @@ class TestMemoryManagerUserIdThreading: session_db.get_session_title_source.return_value = "llm" with patch( - "run_agent.get_tool_definitions", + "model_tools.get_tool_definitions", return_value=[], ), patch( - "run_agent.check_toolset_requirements", + "model_tools.check_toolset_requirements", return_value={}, ), patch( - "run_agent.OpenAI", + "agent.process_bootstrap.OpenAI", ), patch( "hermes_cli.config.load_config_readonly", return_value={"memory": {"provider": "recording"}}, @@ -143,6 +145,42 @@ class TestMemoryManagerUserIdThreading: assert provider._init_kwargs["cwd"] == str(tmp_path) agent.close() + @pytest.mark.parametrize("cwd", [None, ""]) + def test_cwdless_agent_still_initializes_memory_provider(self, cwd): + from run_agent import AIAgent + + provider = RecordingProvider() + + with patch( + "model_tools.get_tool_definitions", + return_value=[], + ), patch( + "model_tools.check_toolset_requirements", + return_value={}, + ), patch( + "agent.process_bootstrap.OpenAI", + ), patch( + "hermes_cli.config.load_config_readonly", + return_value={"memory": {"provider": "recording"}}, + ), patch( + "plugins.memory.load_memory_provider", + return_value=provider, + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + platform="cli", + session_id="session-without-cwd", + cwd=cwd, + ) + + assert agent.session_cwd is None + assert provider._init_session_id == "session-without-cwd" + assert "cwd" not in provider._init_kwargs + agent.close() + # --------------------------------------------------------------------------- # Mem0 provider user_id tests diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 6a4361dd34..64245b6fd6 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -12,6 +12,9 @@ from hermes_cli.profiles import _get_default_hermes_home import pytest +import hermes_state_common +from hermes_state import SessionDB +from plugins.memory.honcho import client as honcho_client from plugins.memory.honcho.client import ( HonchoClientConfig, get_honcho_client, @@ -24,6 +27,19 @@ from plugins.memory.honcho.client import ( class TestHonchoClientConfigDefaults: + def test_automatic_title_sources_share_state_store_vocabulary(self): + automatic_sources = frozenset( + { + hermes_state_common.TITLE_SOURCE_DERIVED, + hermes_state_common.TITLE_SOURCE_LLM, + } + ) + + assert honcho_client._AUTOMATIC_SESSION_TITLE_SOURCES == automatic_sources + assert SessionDB.TITLE_SOURCE_DERIVED == hermes_state_common.TITLE_SOURCE_DERIVED + assert SessionDB.TITLE_SOURCE_LLM == hermes_state_common.TITLE_SOURCE_LLM + assert SessionDB.TITLE_SOURCE_USER == hermes_state_common.TITLE_SOURCE_USER + def test_default_values(self): config = HonchoClientConfig() assert config.host == "hermes" diff --git a/tests/tui_gateway/test_deferred_agent_build_cwd.py b/tests/tui_gateway/test_deferred_agent_build_cwd.py new file mode 100644 index 0000000000..7b356206f1 --- /dev/null +++ b/tests/tui_gateway/test_deferred_agent_build_cwd.py @@ -0,0 +1,59 @@ +"""Regression coverage for logical cwd propagation in deferred TUI/Desktop builds.""" + +import threading +import uuid +from types import SimpleNamespace + +from tui_gateway import server + + +def test_deferred_agent_build_threads_session_cwd(monkeypatch, tmp_path): + """A cold Desktop build must not fall back to the serve process cwd.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + captured = {} + built = threading.Event() + ready = threading.Event() + sid = f"cwd-build-{uuid.uuid4().hex[:8]}" + session = { + "agent_ready": ready, + "session_key": f"cwd-key-{uuid.uuid4().hex[:8]}", + "cwd": str(workspace), + } + + def fake_set_session_context(key, cwd=None): + captured["context_key"] = key + captured["context_cwd"] = cwd + return [] + + def fake_make_agent(*args, **kwargs): + captured["agent_cwd"] = kwargs.get("cwd_override") + built.set() + return SimpleNamespace(model="test", session_id=session["session_key"]) + + monkeypatch.setattr(server, "_set_session_context", fake_set_session_context) + monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None) + monkeypatch.setattr(server, "_make_agent", fake_make_agent) + monkeypatch.setattr( + "tui_gateway.entry.ensure_mcp_discovery_started", lambda: None + ) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_config_model_target", lambda: ("", "")) + monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None) + monkeypatch.setattr(server, "_schedule_mcp_late_refresh", lambda *a, **k: None) + monkeypatch.setattr(server, "_emit", lambda *a, **k: None) + monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None) + + server._sessions[sid] = session + try: + server._start_agent_build(sid, session) + assert built.wait(timeout=15), "agent build thread never called _make_agent" + assert ready.wait(timeout=5), "agent_ready never set after build" + finally: + server._sessions.pop(sid, None) + from tools.approval import unregister_gateway_notify + + unregister_gateway_notify(session["session_key"]) + + assert captured["context_cwd"] == str(workspace) + assert captured["agent_cwd"] == str(workspace) diff --git a/tests/tui_gateway/test_tui_gateway_server.py b/tests/tui_gateway/test_tui_gateway_server.py index ec58712125..2a808838ec 100644 --- a/tests/tui_gateway/test_tui_gateway_server.py +++ b/tests/tui_gateway/test_tui_gateway_server.py @@ -19682,7 +19682,9 @@ def test_start_agent_build_passes_session_model_override( captured.update(kwargs) return types.SimpleNamespace(model="claude-sonnet-4.6") - monkeypatch.setattr(server, "_set_session_context", lambda target: []) + monkeypatch.setattr( + server, "_set_session_context", lambda target, cwd=None: [] + ) monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None) monkeypatch.setattr(server, "_make_agent", fake_make_agent) monkeypatch.setattr(server, "_SlashWorker", FakeWorker)