diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py index f2103e2da3..fadca305c3 100644 --- a/hermes_cli/cli_chat_turn_mixin.py +++ b/hermes_cli/cli_chat_turn_mixin.py @@ -528,11 +528,15 @@ class CLIChatTurnMixin: lost = lost_admission(connection.reattach(self.session_id), admission_id) connection.call("prompt.resolve_unknown", session_id=self.session_id, admission_id=lost["admission_id"], execution_generation=lost["execution_generation"]) - connection.reattach(self.session_id) # the cached pending list the next prompt checks + # The cached pending list the next prompt checks: any other lost turn still refuses it. + snapshot = connection.reattach(self.session_id) except GatewayClientError as exc: _cprint(f" /discard: {exc}") return - _cprint(f" Dropped {lost['admission_id']}; it will not be re-run. You can send messages again.") + left = sum(row["status"] == "unknown" for row in snapshot.get("pending", [])) + _cprint(f" Discarded {lost['admission_id']}; it was not re-run. " + + (f"{left} more paused turn{'s' if left > 1 else ''} to /discard first." if left + else "You can send messages again.")) def _chat_render_turn(self, turn, agent_thread, interrupt_msg): """Post-turn display: errors, interrupt marker, reasoning/response panels, bell, re-queues. @@ -724,6 +728,10 @@ class CLIChatTurnMixin: (self._stream_started and self._stream_box_opened) or _interrupted_streamed ) and not is_error_response modern = getattr(self, "_modern_layout", False) + # A gateway refusal is Hermes's own copy (quoted user text, a /discard line to copy): + # markdown stripping or rendering would rewrite the quote and merge the lines. + mode = ("raw" if turn.result and turn.result.get("failure_reason") == "gateway_connection" + else self.final_response_markdown) if turn.use_streaming_tts and turn.box_opened and not is_error_response: # Text already printed sentence-by-sentence; just close the box. if modern: @@ -740,12 +748,12 @@ class CLIChatTurnMixin: elif modern: from hermes_cli.cli_modern_layout import answer_label, closing_separator _cprint(answer_label()) - ChatConsole().print(_render_final_assistant_content(response, mode=self.final_response_markdown), + ChatConsole().print(_render_final_assistant_content(response, mode=mode), style=_resp_text, soft_wrap=True) # terminal wraps; resize re-flows _cprint(closing_separator(True)) else: ChatConsole().print(Panel( - _render_final_assistant_content(response, mode=self.final_response_markdown), + _render_final_assistant_content(response, mode=mode), title=f"[{_resp_color} bold]{label}[/]", title_align="left", border_style=_resp_color, style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 0), width=self._scrollback_box_width(), diff --git a/tests/hermes_cli/test_cli_lost_turn_notice.py b/tests/hermes_cli/test_cli_lost_turn_notice.py new file mode 100644 index 0000000000..d21126bcc3 --- /dev/null +++ b/tests/hermes_cli/test_cli_lost_turn_notice.py @@ -0,0 +1,55 @@ +"""The classic CLI's refusal behind a lost turn and its ``/discard`` reply say only what is true.""" +from types import SimpleNamespace + +import pytest + + +@pytest.mark.parametrize("mode", ["strip", "render"]) +def test_refused_notice_prints_the_users_text_and_discard_line_as_written(monkeypatch, mode): + """The notice is Hermes's copy, not a model answer: the markdown display modes must neither + rewrite the quoted prompt nor merge the ``/discard`` line into the paragraph around it.""" + import cli as cli_mod + from rich.console import Console + from hermes_cli.gateway_chat_view import pending_lines + + text = "fix the *bug* in __init__.py and `run` it" + notice = "Your message was not sent.\n" + "\n".join(pending_lines([ + {"admission_id": "a1", "status": "unknown", "text": text}, + {"admission_id": "q1", "status": "queued", "text": "next"}])) + console = Console(record=True, width=200) + monkeypatch.setattr(cli_mod, "ChatConsole", lambda: console) + stub = SimpleNamespace(final_response_markdown=mode, _last_turn_interrupted=False, _streamed_text_this_turn="", + _stream_started=False, _stream_box_opened=False, _modern_layout=False, + _scrollback_box_width=lambda: 200) + turn = SimpleNamespace(use_streaming_tts=False, box_opened=False, result={ + "failed": True, "failure_reason": "gateway_connection", "error": notice, "final_response": ""}) + + cli_mod.HermesCLI._chat_print_response_panel(stub, turn, notice) + + lines = [line.strip() for line in console.export_text().splitlines()] + assert f'"{text}"' in lines and "/discard a1" in lines, lines + + +def test_discard_says_when_another_lost_turn_still_blocks_the_chat(monkeypatch): + import cli as cli_mod + + class Connection: + pending = [{"admission_id": a, "status": "unknown", "execution_generation": 4} for a in ("a1", "a2")] + + def reattach(self, _session_id): + return {"pending": list(self.pending)} + + def call(self, _method, **params): + self.pending = [row for row in self.pending if row["admission_id"] != params["admission_id"]] + + printed = [] + monkeypatch.setattr(cli_mod, "_cprint", lambda line: printed.append(line)) + stub = SimpleNamespace(session_id="s") + connection = Connection() + monkeypatch.setattr("hermes_cli.gateway_side.connection_for", lambda _cli: connection) + + cli_mod.HermesCLI._handle_discard_command(stub, "/discard a1") + cli_mod.HermesCLI._handle_discard_command(stub, "/discard a2") + + assert "send messages again" not in printed[0] and "/discard" in printed[0], printed + assert "send messages again" in printed[1], printed