feat: support numbered resume selection in cli and gateway

This commit is contained in:
daizhonggeng 2026-04-13 12:33:13 +00:00 committed by Teknium
parent 4f4e337c47
commit fef733d56b
5 changed files with 164 additions and 21 deletions

31
cli.py
View File

@ -6172,15 +6172,16 @@ class HermesCLI:
else: else:
print(" Recent sessions:") print(" Recent sessions:")
print() print()
print(f" {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
print(f" {'' * 32} {'' * 40} {'' * 13} {'' * 24}") print(f" {'' * 3} {'' * 32} {'' * 40} {'' * 13} {'' * 24}")
for session in sessions: for idx, session in enumerate(sessions, start=1):
title = session.get("title") or "" title = session.get("title") or ""
preview = (session.get("preview") or "")[:38] preview = (session.get("preview") or "")[:38]
last_active = _relative_time(session.get("last_active")) last_active = _relative_time(session.get("last_active"))
print(f" {title:<32} {preview:<40} {last_active:<13} {session['id']}") print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}")
print() print()
print(" Use /resume <session id or title> to continue where you left off.") print(" Use /resume <number>, /resume <session id>, or /resume <session title> to continue.")
print(" Example: /resume 2")
print() print()
return True return True
@ -6525,7 +6526,7 @@ class HermesCLI:
target = parts[1].strip() if len(parts) > 1 else "" target = parts[1].strip() if len(parts) > 1 else ""
if not target: if not target:
_cprint(" Usage: /resume <session_id_or_title>") _cprint(" Usage: /resume <number|session_id_or_title>")
if self._show_recent_sessions(reason="resume"): if self._show_recent_sessions(reason="resume"):
return return
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.") _cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
@ -6536,10 +6537,20 @@ class HermesCLI:
_cprint(f" {format_session_db_unavailable()}") _cprint(f" {format_session_db_unavailable()}")
return return
# Resolve title or ID # Resolve numbered selection, title, or ID
from hermes_cli.main import _resolve_session_by_name_or_id if target.isdigit():
resolved = _resolve_session_by_name_or_id(target) sessions = self._list_recent_sessions(limit=10)
target_id = resolved or target index = int(target)
if index < 1 or index > len(sessions):
_cprint(f" Resume index {index} is out of range.")
_cprint(" Use /resume with no arguments to see available sessions.")
return
selected = sessions[index - 1]
target_id = selected["id"]
else:
from hermes_cli.main import _resolve_session_by_name_or_id
resolved = _resolve_session_by_name_or_id(target)
target_id = resolved or target
session_meta = self._session_db.get_session(target_id) session_meta = self._session_db.get_session(target_id)
if not session_meta: if not session_meta:

View File

@ -12741,7 +12741,7 @@ class GatewayRunner:
return t("gateway.title.current_no_title", session_id=session_id) return t("gateway.title.current_no_title", session_id=session_id)
async def _handle_resume_command(self, event: MessageEvent) -> str: async def _handle_resume_command(self, event: MessageEvent) -> str:
"""Handle /resume command — switch to a previously-named session.""" """Handle /resume command — list or switch to a previous session."""
if not self._session_db: if not self._session_db:
from hermes_state import format_session_db_unavailable from hermes_state import format_session_db_unavailable
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
@ -12750,30 +12750,44 @@ class GatewayRunner:
session_key = self._session_key_for_source(source) session_key = self._session_key_for_source(source)
name = event.get_command_args().strip() name = event.get_command_args().strip()
def _list_titled_sessions() -> list[dict]:
user_source = source.platform.value if source.platform else None
sessions = self._session_db.list_sessions_rich(source=user_source, limit=10)
return [s for s in sessions if s.get("title")][:10]
if not name: if not name:
# List recent titled sessions for this user/platform # List recent titled sessions for this user/platform
try: try:
user_source = source.platform.value if source.platform else None titled = _list_titled_sessions()
sessions = self._session_db.list_sessions_rich(
source=user_source, limit=10
)
titled = [s for s in sessions if s.get("title")]
if not titled: if not titled:
return t("gateway.resume.no_named_sessions") return t("gateway.resume.no_named_sessions")
lines = [t("gateway.resume.list_header")] lines = [t("gateway.resume.list_header")]
for s in titled[:10]: for idx, s in enumerate(titled[:10], start=1):
title = s["title"] title = s["title"]
preview = s.get("preview", "")[:40] preview = s.get("preview", "")[:40]
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
lines.append(t("gateway.resume.list_item", title=title, preview_part=preview_part)) lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
lines.append(t("gateway.resume.list_footer")) lines.append(t("gateway.resume.list_footer_numbered"))
return "\n".join(lines) return "\n".join(lines)
except Exception as e: except Exception as e:
logger.debug("Failed to list titled sessions: %s", e) logger.debug("Failed to list titled sessions: %s", e)
return t("gateway.resume.list_failed", error=e) return t("gateway.resume.list_failed", error=e)
# Resolve the name to a session ID. # Resolve a numbered choice or a title to a session ID.
target_id = self._session_db.resolve_session_by_title(name) if name.isdigit():
try:
titled = _list_titled_sessions()
except Exception as e:
logger.debug("Failed to list titled sessions for numeric resume: %s", e)
return t("gateway.resume.list_failed", error=e)
index = int(name)
if index < 1 or index > len(titled):
return t("gateway.resume.out_of_range", index=index)
target = titled[index - 1]
target_id = target.get("id")
name = target.get("title") or name
else:
target_id = self._session_db.resolve_session_by_title(name)
if not target_id: if not target_id:
return t("gateway.resume.not_found", name=name) return t("gateway.resume.not_found", name=name)
# Compression creates child continuations that hold the live transcript. # Compression creates child continuations that hold the live transcript.

View File

@ -237,9 +237,12 @@ gateway:
no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later." no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
list_header: "📋 **Named Sessions**\n" list_header: "📋 **Named Sessions**\n"
list_item: "• **{title}**{preview_part}" list_item: "• **{title}**{preview_part}"
list_item_numbered: "{index}. **{title}**{preview_part}"
list_preview_suffix: " — _{preview}_" list_preview_suffix: " — _{preview}_"
list_footer: "\nUsage: `/resume <session name>`" list_footer: "\nUsage: `/resume <session name>`"
list_footer_numbered: "\nUsage: `/resume <session name>` or `/resume <number>` (e.g. `/resume 1` for the most recent)"
list_failed: "Could not list sessions: {error}" list_failed: "Could not list sessions: {error}"
out_of_range: "Resume index {index} is out of range.\nUse `/resume` with no arguments to see available sessions."
not_found: "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions." not_found: "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions."
already_on: "📌 Already on session **{name}**." already_on: "📌 Already on session **{name}**."
switch_failed: "Failed to switch session." switch_failed: "Failed to switch session."

View File

@ -0,0 +1,71 @@
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.session_id = "current_session"
cli_obj._resumed = False
cli_obj._pending_title = None
cli_obj.conversation_history = []
cli_obj.agent = None
cli_obj._session_db = MagicMock()
return cli_obj
class TestCliResumeCommand:
def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
{"id": "sess_001", "title": "Research", "preview": "read docs", "last_active": None},
])
shown = cli_obj._show_recent_sessions(reason="resume")
output = capsys.readouterr().out
assert shown is True
assert "1" in output
assert "2" in output
assert "Coding" in output
assert "Research" in output
assert "/resume 2" in output
assert "/resume <session title>" in output
def test_handle_resume_by_index_switches_to_numbered_session(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
])
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
cli_obj._session_db.get_messages_as_conversation.return_value = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
patch("cli._cprint") as mock_cprint,
):
cli_obj._handle_resume_command("/resume 2")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert cli_obj.session_id == "sess_001"
assert "Resumed session sess_001" in printed
assert "Research" in printed
def test_handle_resume_by_index_out_of_range(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding"},
])
with patch("cli._cprint") as mock_cprint:
cli_obj._handle_resume_command("/resume 9")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert "out of range" in printed.lower()
assert "/resume" in printed
assert cli_obj.session_id == "current_session"

View File

@ -88,6 +88,9 @@ class TestHandleResumeCommand:
assert "Research" in result assert "Research" in result
assert "Coding" in result assert "Coding" in result
assert "Named Sessions" in result assert "Named Sessions" in result
assert "1." in result
assert "2." in result
assert "/resume 1" in result
db.close() db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -104,6 +107,47 @@ class TestHandleResumeCommand:
assert "/title" in result assert "/title" in result
db.close() db.close()
@pytest.mark.asyncio
async def test_resume_by_index(self, tmp_path):
"""Numeric argument resumes the indexed titled session from the list."""
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("sess_001", "telegram")
db.create_session("sess_002", "telegram")
db.set_session_title("sess_001", "Research")
db.set_session_title("sess_002", "Coding")
db.create_session("current_session_001", "telegram")
event = _make_event(text="/resume 2")
runner = _make_runner(session_db=db, current_session_id="current_session_001",
event=event)
result = await runner._handle_resume_command(event)
assert "Resumed" in result
runner.session_store.switch_session.assert_called_once()
call_args = runner.session_store.switch_session.call_args
assert call_args[0][1] == "sess_001"
db.close()
@pytest.mark.asyncio
async def test_resume_index_out_of_range(self, tmp_path):
"""Out-of-range numeric arguments show a helpful error."""
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("sess_001", "telegram")
db.set_session_title("sess_001", "Research")
db.create_session("current_session_001", "telegram")
event = _make_event(text="/resume 9")
runner = _make_runner(session_db=db, current_session_id="current_session_001",
event=event)
result = await runner._handle_resume_command(event)
assert "out of range" in result.lower()
assert "/resume" in result
runner.session_store.switch_session.assert_not_called()
db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resume_by_name(self, tmp_path): async def test_resume_by_name(self, tmp_path):
"""Resolves a title and switches to that session.""" """Resolves a title and switches to that session."""