From 7561da3931ae0bb3f0ec2162acecd2857088cb13 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Fri, 29 May 2026 07:30:21 -0400 Subject: [PATCH 1/2] fix(cli): resume the selected chat when a bare number follows /resume A bare `/resume` printed the recent-sessions list but armed no selection state, so typing just `3` on the next line was sent to the agent as chat instead of resuming session #3. `/resume 3` worked, but the natural list-then-pick flow did not. Arm a one-shot pending-resume prompt when bare `/resume` shows the list, and consume the next bare numeric input as the selection (out-of-range is reported, non-numeric/other commands disarm it). Resolves against the same _list_recent_sessions(limit=10) list used everywhere else. Closes #34584. --- cli.py | 75 ++++++++++++++++++- tests/cli/test_cli_resume_command.py | 105 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index c7832ab379496..2bc64c9c5b47c 100644 --- a/cli.py +++ b/cli.py @@ -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 @@ -6693,10 +6699,21 @@ def _handle_resume_command(self, cmd_original: str) -> None: if not target: _cprint(" Usage: /resume ") 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 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 @@ 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|] — browse or resume previous sessions. @@ -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 @@ -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: diff --git a/tests/cli/test_cli_resume_command.py b/tests/cli/test_cli_resume_command.py index 6368d973c8814..eb691ab006a0b 100644 --- a/tests/cli/test_cli_resume_command.py +++ b/tests/cli/test_cli_resume_command.py @@ -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 @@ def test_handle_resume_does_not_strip_partial_brackets(self): printed = " ".join(str(call) for call in mock_cprint.call_args_list) assert " Date: Fri, 29 May 2026 13:16:19 -0700 Subject: [PATCH 2/2] test(tui-gateway): isolate completion_queue in poller requeue test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_notification_poller_requeues_when_busy drained and reused the process-global process_registry.completion_queue, so a concurrent test in the same xdist worker could put/get on the shared singleton mid-run and empty the event the poller requeues — flaking 'assert not completion_queue.empty()' under parallel CI load only. Monkeypatch a fresh Queue onto the singleton for the test's duration so nothing external can interleave. The poller reads completion_queue by attribute at runtime, so the isolated queue is what it operates on. monkeypatch restores the original on teardown. Verified immune: 50/50 passes under a background thread hammering the global queue. --- tests/test_tui_gateway_server.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2631dab378740..4524fb88cb68f 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 = [] @@ -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()