diff --git a/cli.py b/cli.py index 93c6bcfb7d1..dcd97139809 100644 --- a/cli.py +++ b/cli.py @@ -6172,15 +6172,16 @@ class HermesCLI: else: print(" Recent sessions:") print() - print(f" {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") - print(f" {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}") - for session in sessions: + print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") + print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}") + for idx, session in enumerate(sessions, start=1): title = session.get("title") or "—" preview = (session.get("preview") or "")[:38] 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(" Use /resume to continue where you left off.") + print(" Use /resume , /resume , or /resume to continue.") + print(" Example: /resume 2") print() return True @@ -6525,7 +6526,7 @@ class HermesCLI: target = parts[1].strip() if len(parts) > 1 else "" if not target: - _cprint(" Usage: /resume ") + _cprint(" Usage: /resume ") if self._show_recent_sessions(reason="resume"): return _cprint(" Tip: Use /history or `hermes sessions list` to find sessions.") @@ -6536,10 +6537,20 @@ class HermesCLI: _cprint(f" {format_session_db_unavailable()}") return - # Resolve title or ID - from hermes_cli.main import _resolve_session_by_name_or_id - resolved = _resolve_session_by_name_or_id(target) - target_id = resolved or target + # Resolve numbered selection, title, or ID + if target.isdigit(): + sessions = self._list_recent_sessions(limit=10) + 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) if not session_meta: diff --git a/gateway/run.py b/gateway/run.py index b9acc0bc70c..5089586386e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12741,7 +12741,7 @@ class GatewayRunner: return t("gateway.title.current_no_title", session_id=session_id) 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: from hermes_state import format_session_db_unavailable 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) 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: # List recent titled sessions for this user/platform try: - user_source = source.platform.value if source.platform else None - sessions = self._session_db.list_sessions_rich( - source=user_source, limit=10 - ) - titled = [s for s in sessions if s.get("title")] + titled = _list_titled_sessions() if not titled: return t("gateway.resume.no_named_sessions") lines = [t("gateway.resume.list_header")] - for s in titled[:10]: + for idx, s in enumerate(titled[:10], start=1): title = s["title"] preview = s.get("preview", "")[:40] 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_footer")) + lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part)) + lines.append(t("gateway.resume.list_footer_numbered")) return "\n".join(lines) except Exception as e: logger.debug("Failed to list titled sessions: %s", e) return t("gateway.resume.list_failed", error=e) - # Resolve the name to a session ID. - target_id = self._session_db.resolve_session_by_title(name) + # Resolve a numbered choice or a title to a session ID. + 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: return t("gateway.resume.not_found", name=name) # Compression creates child continuations that hold the live transcript. diff --git a/locales/en.yaml b/locales/en.yaml index cbb61055fc8..88d18a2f892 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -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." list_header: "📋 **Named Sessions**\n" list_item: "• **{title}**{preview_part}" + list_item_numbered: "{index}. **{title}**{preview_part}" list_preview_suffix: " — _{preview}_" list_footer: "\nUsage: `/resume `" + list_footer_numbered: "\nUsage: `/resume ` or `/resume ` (e.g. `/resume 1` for the most recent)" 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." already_on: "📌 Already on session **{name}**." switch_failed: "Failed to switch session." diff --git a/tests/cli/test_cli_resume_command.py b/tests/cli/test_cli_resume_command.py new file mode 100644 index 00000000000..c6298263079 --- /dev/null +++ b/tests/cli/test_cli_resume_command.py @@ -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 " 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" diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index 0d2060ef31f..288193132a9 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -88,6 +88,9 @@ class TestHandleResumeCommand: assert "Research" in result assert "Coding" in result assert "Named Sessions" in result + assert "1." in result + assert "2." in result + assert "/resume 1" in result db.close() @pytest.mark.asyncio @@ -104,6 +107,47 @@ class TestHandleResumeCommand: assert "/title" in result 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 async def test_resume_by_name(self, tmp_path): """Resolves a title and switches to that session."""