diff --git a/cli.py b/cli.py index 617e5c15b1..f426fab2bd 100644 --- a/cli.py +++ b/cli.py @@ -4932,7 +4932,7 @@ class HermesCLI: except Exception: pass - def new_session(self, silent=False): + def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" if self.agent and self.conversation_history: # Trigger memory extraction on the old session before session_id rotates. @@ -4987,6 +4987,15 @@ class HermesCLI: self.agent._session_db_created = True except Exception: pass + if title and self._session_db: + try: + from hermes_state import SessionDB + sanitized = SessionDB.sanitize_title(title) + if sanitized: + self._session_db.set_session_title(self.session_id, sanitized) + self._pending_title = None + except Exception: + pass # Notify memory providers that session_id rotated to a fresh # conversation. reset=True signals providers to flush accumulated # per-session state (_session_turns, _turn_counter, _document_id). @@ -5006,7 +5015,10 @@ class HermesCLI: self._notify_session_boundary("on_session_reset") if not silent: - print("(^_^)v New session started!") + if title: + print(f"(^_^)v New session started: {title}") + else: + print("(^_^)v New session started!") def _handle_resume_command(self, cmd_original: str) -> None: """Handle /resume — switch to a previous session mid-conversation.""" @@ -6418,7 +6430,9 @@ class HermesCLI: else: _cprint(" Session database not available.") elif canonical == "new": - self.new_session() + parts = cmd_original.split(maxsplit=1) + title = parts[1].strip() if len(parts) > 1 else None + self.new_session(title=title) elif canonical == "resume": self._handle_resume_command(cmd_original) elif canonical == "model": diff --git a/gateway/run.py b/gateway/run.py index f023b0d349..8c7863c07a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6896,6 +6896,17 @@ class GatewayRunner: new_entry = self.session_store.get_or_create_session(source, force_new=True) header = "✨ New session started!" + # Set session title if provided with /new + _title_arg = event.get_command_args().strip() + if _title_arg and self._session_db and new_entry: + try: + from hermes_state import SessionDB + sanitized = SessionDB.sanitize_title(_title_arg) + if sanitized: + self._session_db.set_session_title(new_entry.session_id, sanitized) + except Exception: + pass + # Fire plugin on_session_reset hook (new session guaranteed to exist) try: from hermes_cli.plugins import invoke_hook as _invoke_hook diff --git a/gateway/session.py b/gateway/session.py index 3129f7a325..16de296e0e 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1121,7 +1121,7 @@ class SessionStore: self._save() return count - def reset_session(self, session_key: str) -> Optional[SessionEntry]: + def reset_session(self, session_key: str, display_name: Optional[str] = None) -> Optional[SessionEntry]: """Force reset a session, creating a new session ID.""" db_end_session_id = None db_create_kwargs = None @@ -1145,7 +1145,7 @@ class SessionStore: created_at=now, updated_at=now, origin=old_entry.origin, - display_name=old_entry.display_name, + display_name=display_name if display_name is not None else old_entry.display_name, platform=old_entry.platform, chat_type=old_entry.chat_type, is_fresh_reset=True, diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 827e7592c4..c7ddfa0fa0 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -64,7 +64,7 @@ class CommandDef: COMMAND_REGISTRY: list[CommandDef] = [ # Session CommandDef("new", "Start a new session (fresh session ID + history)", "Session", - aliases=("reset",)), + aliases=("reset",), args_hint="[name]"), CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True), CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session", diff --git a/tests/cli/test_cli_new_session.py b/tests/cli/test_cli_new_session.py index 63d07d26d2..b2763d9b4f 100644 --- a/tests/cli/test_cli_new_session.py +++ b/tests/cli/test_cli_new_session.py @@ -5,7 +5,7 @@ from __future__ import annotations import importlib import os import sys -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import MagicMock, patch from hermes_state import SessionDB @@ -219,3 +219,22 @@ def test_new_session_resets_token_counters(tmp_path): assert comp.last_total_tokens == 0 assert comp.compression_count == 0 assert comp._context_probed is False + + +def test_new_session_with_title(capsys): + """new_session(title=...) creates a session and sets the title.""" + cli = _make_cli() + cli._session_db = MagicMock() + cli.agent = _FakeAgent("old_session_id", datetime.now()) + cli.conversation_history = [] + + cli.new_session(title="My Test Session") + + # Assert set_session_title was called with the new session ID and sanitized title + cli._session_db.set_session_title.assert_called_once() + call_args = cli._session_db.set_session_title.call_args + assert call_args[0][0] == cli.session_id + assert call_args[0][1] == "My Test Session" + + captured = capsys.readouterr() + assert "My Test Session" in captured.out diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py index d5bad6c57a..4a57771e7d 100644 --- a/tests/gateway/test_title_command.py +++ b/tests/gateway/test_title_command.py @@ -5,11 +5,12 @@ across all gateway messenger platforms. """ import os -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from gateway.config import Platform +from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent from gateway.session import SessionSource @@ -206,3 +207,87 @@ class TestTitleInHelp: import inspect source = inspect.getsource(GatewayRunner._handle_message) assert '"title"' in source + + +# --------------------------------------------------------------------------- +# /new with title +# --------------------------------------------------------------------------- + + +class TestResetCommandWithTitle: + """Tests for GatewayRunner._handle_reset_command with a title argument.""" + + @pytest.mark.asyncio + async def test_reset_command_with_title(self): + """Sending /new <title> resets session and sets the title.""" + from datetime import datetime + + from gateway.run import GatewayRunner + from gateway.session import SessionEntry, SessionSource, build_session_key + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + adapter = MagicMock() + adapter.send = AsyncMock() + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner._session_model_overrides = {} + runner._pending_model_notes = {} + runner._background_tasks = set() + + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="12345", + chat_id="67890", + user_name="testuser", + ) + session_key = build_session_key(source) + new_session_entry = SessionEntry( + session_key=session_key, + session_id="sess-new", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = new_session_entry + runner.session_store.reset_session.return_value = new_session_entry + runner.session_store._entries = {session_key: new_session_entry} + runner.session_store._generate_session_key.return_value = session_key + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_db = MagicMock() + runner._agent_cache = {} + runner._agent_cache_lock = None + runner._is_user_authorized = lambda _source: True + runner._format_session_info = lambda: "" + + event = _make_event(text="/new Custom Name") + result = await runner._handle_reset_command(event) + + runner.session_store.reset_session.assert_called_once() + runner._session_db.set_session_title.assert_called_once_with( + "sess-new", "Custom Name" + ) + + +# --------------------------------------------------------------------------- +# /new in help output +# --------------------------------------------------------------------------- + + +class TestNewInHelp: + """Verify /new appears in help text with the [name] args hint.""" + + def test_new_command_in_help_output(self): + """The gateway help output includes /new with the [name] hint.""" + from hermes_cli.commands import gateway_help_lines + lines = gateway_help_lines() + new_line = next((line for line in lines if line.startswith("`/new ")), None) + assert new_line is not None + assert "[name]" in new_line