diff --git a/cli.py b/cli.py index f426fab2bd..e8c804a9e8 100644 --- a/cli.py +++ b/cli.py @@ -4988,14 +4988,27 @@ class HermesCLI: except Exception: pass if title and self._session_db: + from hermes_state import SessionDB try: - from hermes_state import SessionDB sanitized = SessionDB.sanitize_title(title) - if sanitized: + except ValueError as e: + _cprint(f" Title rejected: {e}") + sanitized = None + title = None + if sanitized: + try: self._session_db.set_session_title(self.session_id, sanitized) self._pending_title = None - except Exception: - pass + title = sanitized + except ValueError as e: + _cprint(f" {e} — session started untitled.") + title = None + except Exception: + title = None + elif title is not None: + # sanitize_title returned empty (whitespace-only / unprintable) + _cprint(" Title is empty after cleanup — session started untitled.") + title = None # 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). diff --git a/gateway/run.py b/gateway/run.py index 8c7863c07a..4ca4711cdd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6898,14 +6898,26 @@ class GatewayRunner: # Set session title if provided with /new _title_arg = event.get_command_args().strip() + _title_note = "" if _title_arg and self._session_db and new_entry: + from hermes_state import SessionDB try: - from hermes_state import SessionDB sanitized = SessionDB.sanitize_title(_title_arg) - if sanitized: + except ValueError as e: + sanitized = None + _title_note = f"\n⚠️ Title rejected: {e}" + if sanitized: + try: self._session_db.set_session_title(new_entry.session_id, sanitized) - except Exception: - pass + header = f"✨ New session started: {sanitized}" + except ValueError as e: + _title_note = f"\n⚠️ {e} — session started untitled." + except Exception: + pass + elif not _title_note: + # sanitize_title returned empty (whitespace-only / unprintable) + _title_note = "\n⚠️ Title is empty after cleanup — session started untitled." + header = header + _title_note # Fire plugin on_session_reset hook (new session guaranteed to exist) try: diff --git a/scripts/release.py b/scripts/release.py index 245badbe6c..2a4965f023 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -513,6 +513,7 @@ AUTHOR_MAP = { "nftpoetrist@gmail.com": "nftpoetrist", # PR #18982 "millerc79@users.noreply.github.com": "millerc79", # PR #19033 "hermes@example.com": "shellybotmoyer", # PR #18915 (bot-committed) + "exx@example.com": "exxmen", # PR #19555 "hypnosis.mda@gmail.com": "Hypn0sis", "ywt000818@gmail.com": "OwenYWT", "dhandhalyabhavik@gmail.com": "v1k22", diff --git a/tests/cli/test_cli_new_session.py b/tests/cli/test_cli_new_session.py index b2763d9b4f..4f453fea32 100644 --- a/tests/cli/test_cli_new_session.py +++ b/tests/cli/test_cli_new_session.py @@ -238,3 +238,40 @@ def test_new_session_with_title(capsys): captured = capsys.readouterr() assert "My Test Session" in captured.out + + +def test_new_session_with_duplicate_title_surfaces_error(capsys): + """new_session(title=...) handles ValueError from a duplicate-title conflict. + + The session is still created; the title assignment fails; the success banner + must not claim the rejected title as the session name. + """ + cli = _make_cli() + cli._session_db = MagicMock() + cli._session_db.set_session_title.side_effect = ValueError( + "Title 'Dup' is already in use by session abc-123" + ) + cli.agent = _FakeAgent("old_session_id", datetime.now()) + cli.conversation_history = [] + + # Capture warnings printed via cli._cprint. After importlib.reload(), + # the method's __globals__ dict is the one from the live module — patch + # the exact dict the method will read. + warnings: list[str] = [] + method_globals = cli.new_session.__globals__ + original = method_globals["_cprint"] + method_globals["_cprint"] = lambda msg: warnings.append(msg) + try: + cli.new_session(title="Dup") + finally: + method_globals["_cprint"] = original + + cli._session_db.set_session_title.assert_called_once() + joined = "\n".join(warnings) + assert "already in use" in joined + assert "session started untitled" in joined + + # The success banner must NOT claim the rejected title as the session name. + captured = capsys.readouterr() + assert "New session started: Dup" not in captured.out + assert "New session started!" in captured.out diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py index 4a57771e7d..c09a2202f4 100644 --- a/tests/gateway/test_title_command.py +++ b/tests/gateway/test_title_command.py @@ -274,6 +274,71 @@ class TestResetCommandWithTitle: runner._session_db.set_session_title.assert_called_once_with( "sess-new", "Custom Name" ) + # Header reflects the applied title + assert "Custom Name" in str(result) + + @pytest.mark.asyncio + async def test_reset_command_duplicate_title_surfaces_warning(self): + """/new <title> with an already-in-use title returns a warning in the reply.""" + 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._session_db.set_session_title.side_effect = ValueError( + "Title 'Dup' is already in use by session abc-123" + ) + 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 Dup") + result = await runner._handle_reset_command(event) + + runner._session_db.set_session_title.assert_called_once() + reply = str(result) + assert "already in use" in reply + assert "session started untitled" in reply + # Header must NOT claim the rejected title as the session name + assert "New session started: Dup" not in reply # ---------------------------------------------------------------------------