From d6c488f2dce96a1d1375c8e7e089b54a1e7ae6f4 Mon Sep 17 00:00:00 2001 From: Phil Thomas Date: Wed, 13 May 2026 14:51:06 -0600 Subject: [PATCH] fix(cli): wire /sessions slash command in the classic CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'sessions' command has been registered in the central command registry since #20805 (May 2025) and surfaces in /help and tab-completion, but the classic CLI's process_command() never had an elif branch for it. The canonical name fell through and printed 'Unknown command: sessions'. The TUI side was wired up correctly via the SessionPicker overlay; only the legacy CLI was missing the dispatch. Adds _handle_sessions_command() which mirrors /resume's no-arg behavior inline (the CLI has no overlay primitive equivalent to the TUI picker): - /sessions and /sessions list → print the recent-sessions table - /sessions → delegates to _handle_resume_command Includes regression tests covering the dispatcher wiring (the original bug) plus the three handler branches. --- cli.py | 34 ++++++++++++++++ tests/cli/test_cli_init.py | 83 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/cli.py b/cli.py index 75506adc655..728309733b6 100644 --- a/cli.py +++ b/cli.py @@ -5961,6 +5961,38 @@ class HermesCLI: else: _cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") + def _handle_sessions_command(self, cmd_original: str) -> None: + """Handle /sessions [list|] — browse or resume previous sessions. + + Without arguments, prints the same recent-sessions table that /resume + shows when called without a target, and tells the user how to resume. + With an explicit subcommand or target, delegates to the resume flow so + ``/sessions `` and ``/resume `` behave identically. + + The TUI ships an interactive picker overlay for this command; the + classic CLI prints an inline list because there is no equivalent + overlay primitive here. Without this handler the canonical name + ``sessions`` falls through ``process_command``'s elif chain and + prints ``Unknown command: sessions`` even though the command is + registered in the central COMMAND_REGISTRY. + """ + parts = cmd_original.split(None, 1) + arg = parts[1].strip() if len(parts) > 1 else "" + sub = arg.lower() + + # Bare /sessions or /sessions list — show recent sessions inline. + if not arg or sub in {"list", "ls", "browse"}: + if not self._session_db: + from hermes_state import format_session_db_unavailable + _cprint(f" {format_session_db_unavailable()}") + return + if not self._show_recent_sessions(reason="sessions"): + _cprint(" (._.) No previous sessions yet.") + return + + # /sessions behaves the same as /resume . + self._handle_resume_command(f"/resume {arg}") + def _handle_branch_command(self, cmd_original: str) -> None: """Handle /branch [name] — fork the current session into a new independent copy. @@ -7540,6 +7572,8 @@ class HermesCLI: self.new_session(title=title) elif canonical == "resume": self._handle_resume_command(cmd_original) + elif canonical == "sessions": + self._handle_sessions_command(cmd_original) elif canonical == "model": self._handle_model_switch(cmd_original) elif canonical == "codex-runtime": diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index ee5ffb390d1..8417d64e746 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -319,6 +319,89 @@ class TestHistoryDisplay: assert "Checking Running Hermes Agent" in output assert "Use /resume to continue" in output + def test_sessions_command_no_args_lists_recent_sessions(self, capsys): + """/sessions with no args prints the recent-sessions table (TUI parity). + + Regression test: `sessions` was registered in the central command + registry and surfaced by /help and tab-completion, but the classic + CLI dispatcher had no elif branch for it, so the canonical name fell + through and printed `Unknown command: sessions`. + """ + cli = _make_cli() + cli.session_id = "current" + cli._session_db = MagicMock() + cli._session_db.list_sessions_rich.return_value = [ + { + "id": "20260401_201329_d85961", + "title": "Checking Running Hermes Agent", + "preview": "check running gateways for hermes agent", + "last_active": 0, + }, + ] + + # Drive it through the public dispatcher to also lock in the + # process_command wiring, not just the handler in isolation. + cli.process_command("/sessions") + output = capsys.readouterr().out + + assert "Unknown command" not in output + assert "Recent sessions" in output + assert "Checking Running Hermes Agent" in output + assert "20260401_201329_d85961" in output + + def test_sessions_list_subcommand_lists_recent_sessions(self, capsys): + """/sessions list is an explicit alias for the no-arg list view.""" + cli = _make_cli() + cli.session_id = "current" + cli._session_db = MagicMock() + cli._session_db.list_sessions_rich.return_value = [ + { + "id": "20260401_201329_d85961", + "title": "Checking Running Hermes Agent", + "preview": "check running gateways for hermes agent", + "last_active": 0, + }, + ] + + cli.process_command("/sessions list") + output = capsys.readouterr().out + + assert "Unknown command" not in output + assert "Recent sessions" in output + assert "Checking Running Hermes Agent" in output + + def test_sessions_with_target_delegates_to_resume(self): + """/sessions behaves identically to /resume . + + We intercept `_handle_resume_command` rather than the full resume + machinery (which would otherwise require simulating an entire session + switch). The contract under test is the dispatch wiring. + """ + cli = _make_cli() + with patch.object(cli, "_handle_resume_command") as mock_resume: + cli.process_command("/sessions Checking Running Hermes Agent") + + mock_resume.assert_called_once_with( + "/resume Checking Running Hermes Agent" + ) + + def test_sessions_command_is_dispatched(self): + """/sessions must hit _handle_sessions_command, not fall through. + + Direct test that the process_command elif chain routes the canonical + name to the handler. Without this wiring, /sessions printed + `Unknown command: sessions` even though it was a registered command. + """ + cli = _make_cli() + cli._session_db = None # exercise the no-db path too + + with patch.object(cli, "_handle_sessions_command") as mock_handler: + cli.process_command("/sessions") + + mock_handler.assert_called_once() + called_with = mock_handler.call_args.args[0] + assert called_with.lower().startswith("/sessions") + class TestRootLevelProviderOverride: """Root-level provider/base_url in config.yaml must NOT override model.provider."""