Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4928,7 +4928,7 @@ def _notify_session_boundary(self, event_type: str) -> None:
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.
Expand Down Expand Up @@ -4983,6 +4983,15 @@ def new_session(self, silent=False):
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
Comment on lines +4986 to +4994
# 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).
Expand All @@ -5002,7 +5011,10 @@ def new_session(self, silent=False):
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!")
Comment on lines +5014 to +5017

def _handle_resume_command(self, cmd_original: str) -> None:
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
Expand Down Expand Up @@ -6414,7 +6426,9 @@ def process_command(self, command: str) -> bool:
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":
Expand Down
11 changes: 11 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6874,6 +6874,17 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
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>
_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
Comment on lines +6879 to +6886

# Fire plugin on_session_reset hook (new session guaranteed to exist)
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
Expand Down
4 changes: 2 additions & 2 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1121,7 +1121,7 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int:
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
Expand All @@ -1145,7 +1145,7 @@ def reset_session(self, session_key: str) -> Optional[SessionEntry]:
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,
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 20 additions & 1 deletion tests/cli/test_cli_new_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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
Expand Down Expand Up @@ -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
89 changes: 87 additions & 2 deletions tests/gateway/test_title_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
"""

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

Expand Down Expand Up @@ -206,3 +207,87 @@ def test_title_is_known_command(self):
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
Loading