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)
This commit is contained in:
outpoints
2026-08-28 16:05:23 -07:00
committed by Teknium
parent 5237cab756
commit 44ba32a565
8 changed files with 137 additions and 8 deletions
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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,
+6
View File
@@ -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.
+3 -1
View File
@@ -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)
+41 -3
View File
@@ -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
+16
View File
@@ -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"
@@ -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)
+3 -1
View File
@@ -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)