diff --git a/cli.py b/cli.py index 408df5d5..3e91812c 100755 --- a/cli.py +++ b/cli.py @@ -2183,15 +2183,63 @@ class HermesCLI: flush_tool_summary() print() - def reset_conversation(self): - """Reset the conversation history.""" + def new_session(self, silent=False): + """Start a fresh session with a new session ID and cleared agent state.""" if self.agent and self.conversation_history: try: self.agent.flush_memories(self.conversation_history) except Exception: pass + + old_session_id = self.session_id + if self._session_db and old_session_id: + try: + self._session_db.end_session(old_session_id, "new_session") + except Exception: + pass + + self.session_start = datetime.now() + timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") + short_uuid = uuid.uuid4().hex[:6] + self.session_id = f"{timestamp_str}_{short_uuid}" self.conversation_history = [] - print("(^_^)b Conversation reset!") + self._pending_title = None + self._resumed = False + + if self.agent: + self.agent.session_id = self.session_id + self.agent.session_start = self.session_start + if hasattr(self.agent, "_last_flushed_db_idx"): + self.agent._last_flushed_db_idx = 0 + if hasattr(self.agent, "_todo_store"): + try: + from tools.todo_tool import TodoStore + self.agent._todo_store = TodoStore() + except Exception: + pass + if hasattr(self.agent, "_invalidate_system_prompt"): + self.agent._invalidate_system_prompt() + + if self._session_db: + try: + self._session_db.create_session( + session_id=self.session_id, + source="cli", + model=self.model, + model_config={ + "max_iterations": self.max_turns, + "reasoning_config": self.reasoning_config, + }, + ) + except Exception: + pass + + if not silent: + print("(^_^)v New session started!") + + def reset_conversation(self): + """Reset the conversation by starting a new session.""" + self.new_session() def save_conversation(self): """Save the current conversation to a file.""" @@ -2675,12 +2723,7 @@ class HermesCLI: elif cmd_lower == "/config": self.show_config() elif cmd_lower == "/clear": - # Flush memories before clearing - if self.agent and self.conversation_history: - try: - self.agent.flush_memories(self.conversation_history) - except Exception: - pass + self.new_session(silent=True) # Clear terminal screen. Inside the TUI, Rich's console.clear() # goes through patch_stdout's StdoutProxy which swallows the # screen-clear escape sequences. Use prompt_toolkit's output @@ -2692,8 +2735,6 @@ class HermesCLI: out.flush() else: self.console.clear() - # Reset conversation - self.conversation_history = [] # Show fresh banner. Inside the TUI we must route Rich output # through ChatConsole (which uses prompt_toolkit's native ANSI # renderer) instead of self.console (which writes raw to stdout @@ -2796,7 +2837,7 @@ class HermesCLI: else: _cprint(" Session database not available.") elif cmd_lower in ("/reset", "/new"): - self.reset_conversation() + self.new_session() elif cmd_lower.startswith("/model"): # Use original case so model names like "Anthropic/Claude-Opus-4" are preserved parts = cmd_original.split(maxsplit=1) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a2f3f816..57899cf0 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -16,9 +16,9 @@ from prompt_toolkit.completion import Completer, Completion # Commands organized by category for better help display COMMANDS_BY_CATEGORY = { "Session": { - "/new": "Start a new conversation (reset history)", - "/reset": "Reset conversation only (keep screen)", - "/clear": "Clear screen and reset conversation (fresh start)", + "/new": "Start a new session (fresh session ID + history)", + "/reset": "Start a new session (alias for /new)", + "/clear": "Clear screen and start a new session", "/history": "Show conversation history", "/save": "Save the current conversation", "/retry": "Retry the last message (resend to agent)", diff --git a/tests/test_cli_new_session.py b/tests/test_cli_new_session.py new file mode 100644 index 00000000..7fed48e4 --- /dev/null +++ b/tests/test_cli_new_session.py @@ -0,0 +1,139 @@ +"""Regression tests for CLI fresh-session commands.""" + +from __future__ import annotations + +import importlib +import os +import sys +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from hermes_state import SessionDB +from tools.todo_tool import TodoStore + + +class _FakeAgent: + def __init__(self, session_id: str, session_start): + self.session_id = session_id + self.session_start = session_start + self.model = "anthropic/claude-opus-4.6" + self._last_flushed_db_idx = 7 + self._todo_store = TodoStore() + self._todo_store.write( + [{"id": "t1", "content": "unfinished task", "status": "in_progress"}] + ) + self.flush_memories = MagicMock() + self._invalidate_system_prompt = MagicMock() + + +def _make_cli(env_overrides=None, config_overrides=None, **kwargs): + """Create a HermesCLI instance with minimal mocking.""" + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + if config_overrides: + _clean_config.update(config_overrides) + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + if env_overrides: + clean_env.update(env_overrides) + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict( + "os.environ", clean_env, clear=False + ): + import cli as _cli_mod + + _cli_mod = importlib.reload(_cli_mod) + with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict( + _cli_mod.__dict__, {"CLI_CONFIG": _clean_config} + ): + return _cli_mod.HermesCLI(**kwargs) + + +def _prepare_cli_with_active_session(tmp_path): + cli = _make_cli() + cli._session_db = SessionDB(db_path=tmp_path / "state.db") + cli._session_db.create_session(session_id=cli.session_id, source="cli", model=cli.model) + + cli.agent = _FakeAgent(cli.session_id, cli.session_start) + cli.conversation_history = [{"role": "user", "content": "hello"}] + + old_session_start = cli.session_start - timedelta(seconds=1) + cli.session_start = old_session_start + cli.agent.session_start = old_session_start + return cli + + +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 + old_session_start = cli.session_start + + cli.process_command("/new") + + assert cli.session_id != old_session_id + + old_session = cli._session_db.get_session(old_session_id) + assert old_session is not None + assert old_session["end_reason"] == "new_session" + + new_session = cli._session_db.get_session(cli.session_id) + assert new_session is not None + + cli._session_db.append_message(cli.session_id, role="user", content="next turn") + + assert cli.agent.session_id == cli.session_id + assert cli.agent._last_flushed_db_idx == 0 + assert cli.agent._todo_store.read() == [] + assert cli.session_start > old_session_start + assert cli.agent.session_start == cli.session_start + cli.agent.flush_memories.assert_called_once_with([{"role": "user", "content": "hello"}]) + cli.agent._invalidate_system_prompt.assert_called_once() + + +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 + + cli.process_command("/reset") + + assert cli.session_id != old_session_id + assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session" + assert cli._session_db.get_session(cli.session_id) is not None + + +def test_clear_command_starts_new_session_before_redrawing(tmp_path): + cli = _prepare_cli_with_active_session(tmp_path) + cli.console = MagicMock() + cli.show_banner = MagicMock() + + old_session_id = cli.session_id + cli.process_command("/clear") + + assert cli.session_id != old_session_id + assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session" + assert cli._session_db.get_session(cli.session_id) is not None + cli.console.clear.assert_called_once() + cli.show_banner.assert_called_once() + assert cli.conversation_history == []