Skip to content
Merged
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
75 changes: 74 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3248,6 +3248,12 @@ def __init__(
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
Expand Down Expand Up @@ -6693,10 +6699,21 @@ def _handle_resume_command(self, cmd_original: str) -> None:
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()}")
Expand Down Expand Up @@ -6810,6 +6827,44 @@ def _handle_resume_command(self, cmd_original: str) -> None:
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.

Expand Down Expand Up @@ -8333,7 +8388,14 @@ def process_command(self, command: str) -> bool:
_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
Expand Down Expand Up @@ -14543,6 +14605,17 @@ def process_loop():
+ (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:
Expand Down
105 changes: 105 additions & 0 deletions tests/cli/test_cli_resume_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -116,3 +117,107 @@ def test_handle_resume_does_not_strip_partial_brackets(self):

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
19 changes: 12 additions & 7 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5114,6 +5114,8 @@ def start(self):

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 = []
Expand All @@ -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 = {
Expand All @@ -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()
Expand All @@ -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()
Loading