From 86871ee25aacac6da29a79105cebaeff76499d12 Mon Sep 17 00:00:00 2001 From: novax635 Date: Sat, 23 May 2026 21:14:15 +0300 Subject: [PATCH] fix(cli): synchronize HERMES_SESSION_ID across environment and contextvar during session switches --- agent/agent_init.py | 14 ++++++-------- agent/conversation_compression.py | 8 ++++---- cli.py | 14 ++++++++++++-- gateway/session_context.py | 15 +++++++++++++++ tests/cli/test_branch_command.py | 19 +++++++++++++++++++ tests/cli/test_cli_init.py | 27 +++++++++++++++++++++++++++ tests/cli/test_cli_new_session.py | 26 ++++++++++++++++++++++++++ 7 files changed, 109 insertions(+), 14 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 0c3cff066e2..e20755c5091 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -976,16 +976,14 @@ def init_agent( # Expose session ID to tools (terminal, execute_code) so agents can # reference their own session for --resume commands, cross-session - # coordination, and logging. Uses the ContextVar system from - # session_context.py for concurrency safety (gateway runs multiple - # sessions in one process). Also writes os.environ as fallback for - # CLI mode where ContextVars aren't used. - os.environ["HERMES_SESSION_ID"] = agent.session_id + # coordination, and logging. Keep the ContextVar and os.environ + # fallback synchronized because different tool paths still read both. try: - from gateway.session_context import _SESSION_ID - _SESSION_ID.set(agent.session_id) + from gateway.session_context import set_current_session_id + + set_current_session_id(agent.session_id) except Exception: - pass # CLI/test mode — ContextVar not needed + os.environ["HERMES_SESSION_ID"] = agent.session_id # Session logs go into ~/.hermes/sessions/ alongside gateway sessions hermes_home = get_hermes_home() diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index cd1b133fa4a..a620f343e99 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -381,12 +381,12 @@ def compress_context( agent._session_db.end_session(agent.session_id, "compression") old_session_id = agent.session_id agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - os.environ["HERMES_SESSION_ID"] = agent.session_id try: - from gateway.session_context import _SESSION_ID - _SESSION_ID.set(agent.session_id) + from gateway.session_context import set_current_session_id + + set_current_session_id(agent.session_id) except Exception: - pass + os.environ["HERMES_SESSION_ID"] = agent.session_id agent._session_db_created = False agent._session_db.create_session( session_id=agent.session_id, diff --git a/cli.py b/cli.py index 67939ab1d1a..d05999b73c5 100644 --- a/cli.py +++ b/cli.py @@ -775,8 +775,6 @@ from rich.markup import escape as _escape from rich.panel import Panel from rich.text import Text as _RichText -import fire - # Import agent and tool systems lazily. Bare interactive startup only needs the # prompt; the full agent/tool registry is initialized on first use. def AIAgent(*args, **kwargs): @@ -818,6 +816,13 @@ def validate_toolset(*args, **kwargs): return _validate_toolset(*args, **kwargs) + +def _sync_process_session_id(session_id: str) -> None: + """Keep process-local session-id consumers aligned after CLI switches.""" + from gateway.session_context import set_current_session_id + + set_current_session_id(session_id) + # Cron job system for scheduled tasks (execution is handled by the gateway) def get_job(*args, **kwargs): from cron import get_job as _get_job @@ -6281,6 +6286,7 @@ class HermesCLI: self.conversation_history = [] self._pending_title = None self._resumed = False + _sync_process_session_id(self.session_id) if self.agent: self.agent.session_id = self.session_id @@ -6567,6 +6573,7 @@ class HermesCLI: self.session_id = target_id self._resumed = True self._pending_title = None + _sync_process_session_id(target_id) # Load conversation history (strip transcript-only metadata entries) restored = self._session_db.get_messages_as_conversation(target_id) @@ -6740,6 +6747,7 @@ class HermesCLI: self.session_start = now self._pending_title = None self._resumed = True # Prevents auto-title generation + _sync_process_session_id(new_session_id) # Sync the agent if self.agent: @@ -14777,4 +14785,6 @@ def main( if __name__ == "__main__": + import fire + fire.Fire(main) diff --git a/gateway/session_context.py b/gateway/session_context.py index 486949fae3d..ee43eca0f76 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -83,6 +83,21 @@ _VAR_MAP = { } +def set_current_session_id(session_id: str) -> None: + """Synchronize ``HERMES_SESSION_ID`` across ContextVar and ``os.environ``. + + Long-lived single-process entrypoints like the CLI can rotate sessions via + ``/new``, ``/resume``, ``/branch``, or compression splits without + reconstructing the entire agent. Tools still consult + ``get_session_env("HERMES_SESSION_ID")`` with an ``os.environ`` fallback, + so both storage paths must move together when the active session changes. + """ + import os + + os.environ["HERMES_SESSION_ID"] = session_id + _SESSION_ID.set(session_id) + + def set_session_vars( platform: str = "", chat_id: str = "", diff --git a/tests/cli/test_branch_command.py b/tests/cli/test_branch_command.py index 409ab295fc0..cf48384403f 100644 --- a/tests/cli/test_branch_command.py +++ b/tests/cli/test_branch_command.py @@ -168,6 +168,25 @@ class TestBranchCommandCLI: assert cli_instance._resumed is True + def test_branch_rotates_hermes_session_id_env_and_context(self, cli_instance, session_db): + """Branching must update process-local session-id readers too.""" + from cli import HermesCLI + from gateway.session_context import _UNSET, _VAR_MAP, get_session_env + + old_session_id = cli_instance.session_id + os.environ["HERMES_SESSION_ID"] = old_session_id + _VAR_MAP["HERMES_SESSION_ID"].set(old_session_id) + + try: + HermesCLI._handle_branch_command(cli_instance, "/branch") + + assert cli_instance.session_id != old_session_id + assert os.environ["HERMES_SESSION_ID"] == cli_instance.session_id + assert get_session_env("HERMES_SESSION_ID") == cli_instance.session_id + finally: + os.environ.pop("HERMES_SESSION_ID", None) + _VAR_MAP["HERMES_SESSION_ID"].set(_UNSET) + def test_branch_fires_on_session_switch_hook(self, cli_instance, session_db): """The /branch command must notify memory providers of the rotation. diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index b87325ac4c2..2775eacbbcb 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -333,6 +333,33 @@ class TestHistoryDisplay: assert "Checking Running Hermes Agent" in output assert "Use /resume to continue" in output + def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path): + from gateway.session_context import _UNSET, _VAR_MAP, get_session_env + from hermes_state import SessionDB + + cli = _make_cli() + cli.session_id = "current_session" + cli.conversation_history = [] + cli.agent = None + cli._session_db = SessionDB(db_path=tmp_path / "state.db") + cli._session_db.create_session("current_session", "cli") + cli._session_db.create_session("target_session", "cli") + cli._session_db.append_message("target_session", "user", "hello from resumed session") + + os.environ["HERMES_SESSION_ID"] = "current_session" + _VAR_MAP["HERMES_SESSION_ID"].set("current_session") + + try: + cli._handle_resume_command("/resume target_session") + + assert cli.session_id == "target_session" + assert os.environ["HERMES_SESSION_ID"] == "target_session" + assert get_session_env("HERMES_SESSION_ID") == "target_session" + finally: + cli._session_db.close() + os.environ.pop("HERMES_SESSION_ID", None) + _VAR_MAP["HERMES_SESSION_ID"].set(_UNSET) + def test_sessions_command_no_args_lists_recent_sessions(self, capsys): """/sessions with no args prints the recent-sessions table (TUI parity). diff --git a/tests/cli/test_cli_new_session.py b/tests/cli/test_cli_new_session.py index 05503552cec..c56ab63cf24 100644 --- a/tests/cli/test_cli_new_session.py +++ b/tests/cli/test_cli_new_session.py @@ -8,6 +8,8 @@ import sys from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +import pytest + from hermes_state import SessionDB from tools.todo_tool import TodoStore @@ -138,6 +140,15 @@ def _prepare_cli_with_active_session(tmp_path): return cli +@pytest.fixture(autouse=True) +def _reset_session_id_context(): + from gateway.session_context import _UNSET, _VAR_MAP + + yield + os.environ.pop("HERMES_SESSION_ID", None) + _VAR_MAP["HERMES_SESSION_ID"].set(_UNSET) + + def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path): cli = _prepare_cli_with_active_session(tmp_path) old_session_id = cli.session_id @@ -164,6 +175,21 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path) cli.agent._invalidate_system_prompt.assert_called_once() +def test_new_command_rotates_hermes_session_id_env_and_context(tmp_path): + from gateway.session_context import _VAR_MAP, get_session_env + + cli = _prepare_cli_with_active_session(tmp_path) + old_session_id = cli.session_id + os.environ["HERMES_SESSION_ID"] = old_session_id + _VAR_MAP["HERMES_SESSION_ID"].set(old_session_id) + + cli.process_command("/new") + + assert cli.session_id != old_session_id + assert os.environ["HERMES_SESSION_ID"] == cli.session_id + assert get_session_env("HERMES_SESSION_ID") == cli.session_id + + def test_reset_command_is_alias_for_new_session(tmp_path): cli = _prepare_cli_with_active_session(tmp_path) old_session_id = cli.session_id