Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52524c7aed | ||
|
|
689ef5e233 | ||
|
|
bb50825716 | ||
|
|
9f5afc7636 | ||
|
|
4fd8521e44 | ||
|
|
edfdc77664 |
@@ -3248,6 +3248,12 @@ class HermesCLI:
|
||||
self._slash_confirm_state = None
|
||||
self._slash_confirm_deadline = 0
|
||||
self._model_picker_state = None
|
||||
# Armed when a bare `/resume` prints the recent-sessions list so the
|
||||
# very next bare numeric input (e.g. `3`) resolves to that session.
|
||||
# Holds the exact list used for index resolution; one-shot (cleared on
|
||||
# the next submitted input, whether it's the selection or anything
|
||||
# else). See #34584.
|
||||
self._pending_resume_sessions = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
@@ -6693,10 +6699,21 @@ class HermesCLI:
|
||||
if not target:
|
||||
_cprint(" Usage: /resume <number|session_id_or_title>")
|
||||
if self._show_recent_sessions(reason="resume"):
|
||||
# Arm a one-shot pending-resume selection so the user can type
|
||||
# just the number (`3`) on the next line instead of having to
|
||||
# retype `/resume 3`. The list here must match the one shown by
|
||||
# _show_recent_sessions and used for index resolution below —
|
||||
# all three go through _list_recent_sessions(limit=10). See
|
||||
# #34584.
|
||||
self._pending_resume_sessions = self._list_recent_sessions(limit=10)
|
||||
return
|
||||
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
|
||||
return
|
||||
|
||||
# Any explicit /resume <target> supersedes a previously-armed bare
|
||||
# numbered prompt.
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if not self._session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
_cprint(f" {format_session_db_unavailable()}")
|
||||
@@ -6810,6 +6827,44 @@ class HermesCLI:
|
||||
else:
|
||||
_cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
|
||||
|
||||
def _consume_pending_resume_selection(self, text: str) -> bool:
|
||||
"""Resolve a bare numeric reply that follows a bare ``/resume`` prompt.
|
||||
|
||||
After ``/resume`` (no args) prints the recent-sessions list it arms
|
||||
``self._pending_resume_sessions``. The next submitted input is given
|
||||
one chance to be a bare session number (``3``); if so we resume that
|
||||
session here. Anything else (another command, free text, blank) simply
|
||||
disarms the prompt and is handled normally by the caller.
|
||||
|
||||
Returns True if the input was consumed as a resume selection (caller
|
||||
must not treat it as chat); False otherwise. The pending state is
|
||||
always one-shot: it is cleared on the first submitted input regardless
|
||||
of outcome. See #34584.
|
||||
"""
|
||||
pending = self._pending_resume_sessions
|
||||
if not pending:
|
||||
return False
|
||||
# One-shot: disarm now so a non-matching input can't leave the prompt
|
||||
# armed and hijack a later number the user meant as chat.
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
stripped = text.strip()
|
||||
# Only a pure number selects; let "/resume 3", titles, or any other
|
||||
# text fall through to normal handling.
|
||||
if not stripped.isdigit():
|
||||
return False
|
||||
|
||||
index = int(stripped)
|
||||
if index < 1 or index > len(pending):
|
||||
_cprint(f" Resume index {index} is out of range.")
|
||||
_cprint(" Use /resume with no arguments to see available sessions.")
|
||||
return True
|
||||
|
||||
self._handle_resume_command(f"/resume {index}")
|
||||
return True
|
||||
|
||||
def _handle_sessions_command(self, cmd_original: str) -> None:
|
||||
"""Handle /sessions [list|<id_or_title>] — browse or resume previous sessions.
|
||||
|
||||
@@ -8333,7 +8388,14 @@ class HermesCLI:
|
||||
_base_word = cmd_lower.split()[0].lstrip("/")
|
||||
_cmd_def = _resolve_cmd(_base_word)
|
||||
canonical = _cmd_def.name if _cmd_def else _base_word
|
||||
|
||||
|
||||
# A bare `/resume` prompt is one-shot: any command other than the
|
||||
# resume/sessions handlers (which manage the pending state themselves)
|
||||
# disarms it so a later number isn't swallowed as a stale selection.
|
||||
# See #34584.
|
||||
if canonical not in {"resume", "sessions"}:
|
||||
self._pending_resume_sessions = None
|
||||
|
||||
if canonical in {"quit", "exit"}:
|
||||
# Parse --delete flag: /exit --delete also removes the current
|
||||
# session's transcripts + SQLite history. Ported from
|
||||
@@ -14543,6 +14605,17 @@ class HermesCLI:
|
||||
+ (f"\n{_remainder}" if _remainder else "")
|
||||
)
|
||||
|
||||
# A bare number right after a bare `/resume` prompt selects
|
||||
# that session (see #34584). Checked before chat routing so
|
||||
# the digit isn't sent to the agent as a message.
|
||||
if (
|
||||
not _file_drop
|
||||
and self._pending_resume_sessions
|
||||
and isinstance(user_input, str)
|
||||
and self._consume_pending_resume_selection(user_input)
|
||||
):
|
||||
continue
|
||||
|
||||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||||
_cprint(f"\n⚙️ {user_input}")
|
||||
try:
|
||||
|
||||
+24
-2
@@ -221,7 +221,11 @@ def check_for_updates() -> Optional[int]:
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
# Read cache — invalidate if the embedded rev OR installed version has
|
||||
# changed since the last check. The version guard matters for pip installs:
|
||||
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
|
||||
# changes VERSION but leaves rev unchanged (both None), and without this
|
||||
# the stale "behind" count would survive the upgrade for up to 6h. See #34491.
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
@@ -229,6 +233,7 @@ def check_for_updates() -> Optional[int]:
|
||||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
and cached.get("ver") == VERSION
|
||||
):
|
||||
return cached.get("behind")
|
||||
except Exception:
|
||||
@@ -249,7 +254,9 @@ def check_for_updates() -> Optional[int]:
|
||||
behind = _check_via_local_git(repo_dir)
|
||||
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
cache_file.write_text(
|
||||
json.dumps({"ts": now, "behind": behind, "rev": embedded_rev, "ver": VERSION})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -691,6 +698,21 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
# Pip-install warning — `pip install hermes-agent` is not the supported
|
||||
# install path (it exists on PyPI for internal/CI reasons, not end users).
|
||||
# Such installs miss the git checkout + installer-managed deps, so updates,
|
||||
# self-update, and issue triage don't behave correctly. Warn, don't block.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "pip":
|
||||
right_lines.append(
|
||||
"[bold yellow]⚠ pip install not officially supported[/]"
|
||||
"[dim yellow] — exists for reasons other than user install; "
|
||||
"expect instability and an inability to support issues[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over the install-method check
|
||||
|
||||
right_content = "\n".join(right_lines)
|
||||
layout_table.add_row(left_content, right_content)
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ AUTHOR_MAP = {
|
||||
"steve@steveonjava.com": "steveonjava",
|
||||
"steveonjava@gmail.com": "steveonjava",
|
||||
"squiddy@2rook.ai": "MoonRay305",
|
||||
"annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"32201324+simpolism@users.noreply.github.com": "simpolism",
|
||||
"simpolism@gmail.com": "simpolism",
|
||||
"jake@nousresearch.com": "simpolism",
|
||||
|
||||
@@ -11,6 +11,7 @@ def _make_cli():
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.agent = None
|
||||
cli_obj._session_db = MagicMock()
|
||||
cli_obj._pending_resume_sessions = None
|
||||
# _handle_resume_command now triggers _display_resumed_history (#31695),
|
||||
# which reads self.resume_display. "minimal" short-circuits the recap so
|
||||
# the test only exercises session-switch behavior.
|
||||
@@ -116,3 +117,107 @@ class TestCliResumeCommand:
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert "<half" in printed
|
||||
|
||||
|
||||
class TestPendingResumeNumberedSelection:
|
||||
"""Bare `/resume` arms a one-shot prompt so the next bare number resumes.
|
||||
|
||||
Regression coverage for #34584: previously, running `/resume` (no args)
|
||||
printed the recent-sessions list but left no selection state armed, so
|
||||
typing just `3` on the next line was sent to the agent as chat instead of
|
||||
resuming session #3.
|
||||
"""
|
||||
|
||||
def test_bare_resume_arms_pending_selection(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=True)
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions == sessions
|
||||
|
||||
def test_bare_resume_no_sessions_does_not_arm(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=False)
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[])
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_number_resumes_selected_session(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._pending_resume_sessions = sessions
|
||||
# _handle_resume_command("/resume 2") re-resolves the index via
|
||||
# _list_recent_sessions, so it must return the same list.
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
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"},
|
||||
]
|
||||
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
|
||||
patch("cli._cprint"),
|
||||
):
|
||||
consumed = cli_obj._consume_pending_resume_selection("2")
|
||||
|
||||
assert consumed is True
|
||||
assert cli_obj.session_id == "sess_001"
|
||||
# One-shot: prompt is disarmed after consuming.
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_out_of_range_consumed_with_message(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
consumed = cli_obj._consume_pending_resume_selection("9")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
# An out-of-range number is still consumed (not sent to the agent),
|
||||
# and the prompt is disarmed.
|
||||
assert consumed is True
|
||||
assert "out of range" in printed.lower()
|
||||
assert cli_obj.session_id == "current_session"
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_non_numeric_falls_through_and_disarms(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint"):
|
||||
consumed = cli_obj._consume_pending_resume_selection("hello there")
|
||||
|
||||
# Free text is NOT consumed (caller treats it as chat), but the
|
||||
# one-shot prompt is disarmed so a later number isn't hijacked.
|
||||
assert consumed is False
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_no_pending_returns_false(self):
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
assert cli_obj._consume_pending_resume_selection("3") is False
|
||||
|
||||
def test_pending_disarmed_by_other_command(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
# Stub out the help handler so process_command("/help") is cheap.
|
||||
cli_obj.show_help = MagicMock()
|
||||
|
||||
cli_obj.process_command("/help")
|
||||
|
||||
# A non-resume command disarms the one-shot prompt (#34584).
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
@@ -59,3 +59,53 @@ def test_docker_detected_via_dockerenv(tmp_path):
|
||||
def test_recommended_update_command_docker():
|
||||
from hermes_cli.config import recommended_update_command_for_method
|
||||
assert "docker pull" in recommended_update_command_for_method("docker")
|
||||
|
||||
|
||||
def test_banner_warns_on_pip_install(tmp_path):
|
||||
"""The welcome banner surfaces a warning when the install method is pip."""
|
||||
import io
|
||||
from rich.console import Console
|
||||
from hermes_cli import banner
|
||||
|
||||
hh = tmp_path / ".hermes"
|
||||
hh.mkdir()
|
||||
(hh / ".install_method").write_text("pip\n")
|
||||
|
||||
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=hh):
|
||||
buf = io.StringIO()
|
||||
# Wide console so the warning isn't wrapped across lines in the panel.
|
||||
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
|
||||
banner.build_welcome_banner(
|
||||
console, model="m", cwd="/tmp",
|
||||
tools=[{"function": {"name": "terminal"}}],
|
||||
enabled_toolsets=["terminal"],
|
||||
)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "officially" in out
|
||||
assert "instability" in out
|
||||
|
||||
|
||||
def test_banner_no_pip_warning_on_git_install(tmp_path):
|
||||
"""Git installs must not show the pip-install warning."""
|
||||
import io
|
||||
from rich.console import Console
|
||||
from hermes_cli import banner
|
||||
|
||||
hh = tmp_path / ".hermes"
|
||||
hh.mkdir()
|
||||
(hh / ".install_method").write_text("git\n")
|
||||
|
||||
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=hh):
|
||||
buf = io.StringIO()
|
||||
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
|
||||
banner.build_welcome_banner(
|
||||
console, model="m", cwd="/tmp",
|
||||
tools=[{"function": {"name": "terminal"}}],
|
||||
enabled_toolsets=["terminal"],
|
||||
)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "officially" not in out
|
||||
|
||||
@@ -19,6 +19,7 @@ def test_version_string_no_v_prefix():
|
||||
def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
||||
"""When cache is fresh, check_for_updates should return cached value without calling git."""
|
||||
from hermes_cli.banner import check_for_updates
|
||||
from hermes_cli import __version__
|
||||
|
||||
# Create a fake git repo and fresh cache
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
@@ -26,7 +27,7 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
cache_file = tmp_path / ".update_check"
|
||||
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3}))
|
||||
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3, "ver": __version__}))
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
@@ -36,6 +37,43 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch):
|
||||
"""A fresh cache from a different installed version must be re-checked, not reused.
|
||||
|
||||
Regression for #34491: after `pip install --upgrade`, VERSION changes but the
|
||||
cache's 6h TTL hadn't expired and rev was unchanged (both None), so the stale
|
||||
'behind' count survived the upgrade. The version guard forces a recheck.
|
||||
"""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
# No local git checkout -> the PyPI path is exercised (pip-install class).
|
||||
fake_banner = tmp_path / "hermes_cli" / "banner.py"
|
||||
fake_banner.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_banner.touch()
|
||||
monkeypatch.setattr(banner, "__file__", str(fake_banner))
|
||||
|
||||
# Fresh (within TTL) cache that says "behind", but stamped with an OLD version.
|
||||
cache_file = tmp_path / ".update_check"
|
||||
cache_file.write_text(
|
||||
json.dumps({"ts": time.time(), "behind": 1, "rev": None, "ver": "0.0.1-old"})
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("HERMES_REVISION", raising=False)
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run, \
|
||||
patch("hermes_cli.banner.check_via_pypi", return_value=0) as mock_pypi:
|
||||
result = banner.check_for_updates()
|
||||
|
||||
# Stale-version cache rejected -> fresh check ran -> up-to-date result.
|
||||
assert result == 0
|
||||
mock_pypi.assert_called_once()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
# Cache rewritten with the current installed version.
|
||||
written = json.loads(cache_file.read_text())
|
||||
assert written["ver"] == banner.VERSION
|
||||
|
||||
|
||||
def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
|
||||
"""When cache is expired, check_for_updates should call git fetch."""
|
||||
from hermes_cli.banner import check_for_updates
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
# setuptools is declared in the [dev] extra and is the build backend, but
|
||||
# guard the import so a runner without it skips these packaging checks
|
||||
# instead of erroring out collection for the whole shard (it used to be
|
||||
# picked up ambiently from the CI image; newer ubuntu-latest images don't
|
||||
# ship it in the test venv).
|
||||
find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_packages
|
||||
from setuptools import find_packages
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -5114,6 +5114,8 @@ def test_notification_poller_skips_consumed(monkeypatch):
|
||||
|
||||
def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
"""When the agent is busy, the poller requeues the event."""
|
||||
import queue as _queue_mod
|
||||
|
||||
from tools.process_registry import process_registry
|
||||
|
||||
emitted = []
|
||||
@@ -5122,8 +5124,13 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
server._sessions["sid_busy"] = sess
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
|
||||
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
# Isolate the completion queue for the duration of this test. The poller
|
||||
# reads process_registry.completion_queue by attribute at runtime, so a
|
||||
# fresh Queue here means no concurrently-running test in the same xdist
|
||||
# worker can put/get on the shared singleton mid-run and drain the event
|
||||
# we expect to be requeued. monkeypatch restores the original on teardown.
|
||||
isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
|
||||
monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
|
||||
process_registry._completion_consumed.discard("proc_busy_test")
|
||||
|
||||
evt = {
|
||||
@@ -5133,7 +5140,7 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
"exit_code": 0,
|
||||
"output": "ok",
|
||||
}
|
||||
process_registry.completion_queue.put(evt)
|
||||
isolated_queue.put(evt)
|
||||
|
||||
stop = threading.Event()
|
||||
stop.set()
|
||||
@@ -5146,10 +5153,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
assert len(status_calls) == 1
|
||||
|
||||
# Event was requeued (agent was busy, no turn triggered)
|
||||
assert not process_registry.completion_queue.empty()
|
||||
requeued = process_registry.completion_queue.get_nowait()
|
||||
assert not isolated_queue.empty()
|
||||
requeued = isolated_queue.get_nowait()
|
||||
assert requeued["session_id"] == "proc_busy_test"
|
||||
finally:
|
||||
server._sessions.pop("sid_busy", None)
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
|
||||
+1
-1
@@ -3366,7 +3366,7 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
return_exceptions=True,
|
||||
)
|
||||
for name, result in zip(server_names, results):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, BaseException):
|
||||
command = new_servers.get(name, {}).get("command")
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server '%s'%s: %s",
|
||||
|
||||
Reference in New Issue
Block a user