From cbf5b05c704e3c6c3fb53b3dca2cbd6f0d1ff61e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:33 -0400 Subject: [PATCH 1/6] feat(agent): add active-turn redirect core primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A follow-up sent while the model is still generating previously ended the turn: Hermes kept only the visible partial text (reasoning was display-only), cleared the loop, and replayed the message as a fresh next turn. If the correction referred to something that only appeared in the thinking stream, the model no longer had that context. Add `AIAgent.redirect(text)`: a corrective interrupt distinct from a hard stop. It cancels only the in-flight model request (not tool workers or child agents), stashes the correction under a lock shared with `interrupt()` so a concurrent `/stop` always wins, and lets the loop rebuild the same logical iteration. `_apply_active_turn_redirect()` checkpoints the reasoning that was actually shown to the user plus any visible partial text as an ordinary assistant message, then appends the correction as a real user turn — never replaying incomplete signed/encrypted provider reasoning, and keeping strict role alternation and prompt-cache stability intact. During tool execution it degrades to `steer()` so a running tool finishes at a safe boundary. `_fire_reasoning_delta` now only records reasoning that a display callback actually consumed, so `show_reasoning: false` never leaks hidden provider thinking into the persisted transcript. --- agent/agent_init.py | 15 +++ agent/conversation_loop.py | 144 ++++++++++++++++++--- agent/turn_retry_state.py | 4 + run_agent.py | 179 ++++++++++++++++++++++++++- tests/agent/test_turn_retry_state.py | 1 + tests/run_agent/test_run_agent.py | 131 ++++++++++++++++++++ tests/run_agent/test_steer.py | 175 ++++++++++++++++++++++++++ 7 files changed, 627 insertions(+), 22 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 2094dd2b4f7f..a6d3b0481411 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -720,6 +720,8 @@ def init_agent( agent._execution_thread_id: int | None = None # Set at run_conversation() start agent._interrupt_thread_signal_pending = False agent._client_lock = threading.RLock() + agent._model_request_active = threading.Event() + agent._supports_active_turn_redirect = True # /steer mechanism — inject a user note into the next tool result # without interrupting the agent. Unlike interrupt(), steer() does @@ -731,6 +733,13 @@ def init_agent( agent._pending_steer: Optional[str] = None agent._pending_steer_lock = threading.Lock() + # Active-turn redirect mechanism. A regular follow-up sent while the model + # is generating is different from a hard /stop: preserve the valid turn + # prefix, cancel only the in-flight model request, and rebuild its tail with + # the correction. The loop drains this slot at a role-safe boundary. + agent._pending_redirect: Optional[str] = None + agent._pending_redirect_lock = threading.Lock() + # Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent` # runs each tool on its own ThreadPoolExecutor worker — those worker # threads have tids distinct from `_execution_thread_id`, so @@ -897,6 +906,12 @@ def init_agent( agent._stream_writer_tls = threading.local() agent._stream_writer_dropped = 0 + # Displayed reasoning text streamed during the current model response, + # captured only when a surface consumed it via a reasoning callback. Used + # by active-turn redirect to checkpoint what the user actually saw without + # ever persisting hidden provider reasoning. + agent._current_streamed_reasoning_text = "" + # Optional current-turn user-message override used when the API-facing # user message intentionally differs from the persisted transcript # (e.g. CLI voice mode adds a temporary prefix for the live call only). diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index df81c20be211..fb9d11b66fce 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -103,6 +103,53 @@ _API_CALL_MODULES = frozenset({ }) +def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None: + """Append a provider-safe checkpoint and correction to the live turn. + + Incomplete provider reasoning blocks are not valid replay items (Anthropic + signs them; Responses reasoning items require their following output). + Preserve only what Hermes actually displayed, demoted to ordinary text, + then add the correction as a real user message. This keeps role alternation + valid and leaves every previously cached message byte-for-byte unchanged. + """ + reasoning = str( + getattr(agent, "_current_streamed_reasoning_text", "") or "" + ).strip() + visible = agent._strip_think_blocks( + getattr(agent, "_current_streamed_assistant_text", "") or "" + ).strip() + + checkpoint_parts = ["[This response was interrupted by a user correction.]"] + if reasoning: + checkpoint_parts.extend( + ["Reasoning shown before the interruption:", reasoning] + ) + if visible: + checkpoint_parts.extend( + ["Visible response before the interruption:", visible] + ) + checkpoint = "\n\n".join(checkpoint_parts) + + # The normal live tail is user or tool, so an assistant checkpoint followed + # by the correction preserves strict alternation. If a transport already + # committed an assistant item, attribute the checkpoint inside the user + # correction instead of creating assistant→assistant. + if messages and messages[-1].get("role") == "assistant": + correction = ( + "[Context from the interrupted assistant response]\n" + f"{checkpoint}\n\n" + f"{text}" + ) + messages.append({"role": "user", "content": correction}) + else: + messages.append({"role": "assistant", "content": checkpoint}) + messages.append({"role": "user", "content": text}) + + agent._current_streamed_assistant_text = "" + agent._current_streamed_reasoning_text = "" + agent._stream_needs_break = True + + def _image_error_max_dimension(error: Exception) -> Optional[int]: """Extract a provider-reported image dimension ceiling, if present.""" parts = [] @@ -733,6 +780,16 @@ def run_conversation( ) while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: + _redirect_text = agent._drain_pending_redirect() + if _redirect_text: + _apply_active_turn_redirect(agent, messages, _redirect_text) + if isinstance(original_user_message, str): + original_user_message = ( + f"{original_user_message}\n\n" + f"User correction during the turn: {_redirect_text}" + ) + agent._persist_session(messages, conversation_history) + # Reset per-turn checkpoint dedup so each iteration can take one snapshot agent._checkpoint_mgr.new_turn() @@ -1532,22 +1589,59 @@ def run_conversation( from hermes_cli.middleware import run_llm_execution_middleware - response = run_llm_execution_middleware( - api_kwargs, - _perform_api_call, - original_request=_original_api_kwargs, - task_id=effective_task_id, - turn_id=turn_id, - api_request_id=api_request_id, - session_id=agent.session_id or "", - platform=agent.platform or "", - model=agent.model, - provider=agent.provider, - base_url=agent.base_url, - api_mode=agent.api_mode, - api_call_count=api_call_count, - middleware_trace=list(_llm_middleware_trace), - ) + _model_request_active = getattr(agent, "_model_request_active", None) + _redirect_lock = getattr(agent, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.set() + elif _model_request_active is not None: + _model_request_active.set() + _redirect_crossed_response = False + try: + response = run_llm_execution_middleware( + api_kwargs, + _perform_api_call, + original_request=_original_api_kwargs, + task_id=effective_task_id, + turn_id=turn_id, + api_request_id=api_request_id, + session_id=agent.session_id or "", + platform=agent.platform or "", + model=agent.model, + provider=agent.provider, + base_url=agent.base_url, + api_mode=agent.api_mode, + api_call_count=api_call_count, + middleware_trace=list(_llm_middleware_trace), + ) + finally: + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = bool( + agent._pending_redirect + ) + else: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = agent._has_pending_redirect() + if _redirect_crossed_response: + # The response and redirect can cross on different threads: + # redirect() observed the request as active just before this + # call returned. Discard that now-stale response and rebuild + # from the correction rather than silently losing it. + if thinking_spinner: + thinking_spinner.stop("") + thinking_spinner = None + if agent.thinking_callback: + agent.thinking_callback("") + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + else: + interrupted = True + break api_duration = time.time() - api_start_time @@ -2509,6 +2603,15 @@ def run_conversation( thinking_spinner = None if agent.thinking_callback: agent.thinking_callback("") + if agent._has_pending_redirect(): + # redirect() deliberately used the interrupt machinery to + # cancel only this provider request. Keep its correction + # queued, clear the cancellation bit, and let the outer + # loop rebuild a clean request tail. Never materialize + # incomplete signed/encrypted reasoning items. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break api_elapsed = time.time() - api_start_time agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True) interrupted = True @@ -4457,6 +4560,15 @@ def run_conversation( f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + # The cancelled request produced no valid assistant item. Reuse the + # same logical iteration after the outer loop appends the displayed + # partial context and correction to ``messages``. + api_call_count -= 1 + agent.iteration_budget.refund() + _retry.restart_with_redirected_messages = False + continue + # If the API call was interrupted, skip response processing if interrupted: _turn_exit_reason = "interrupted_during_api_call" diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index 3d231fef9ff4..59e343bfeda3 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -73,6 +73,10 @@ class TurnRetryState: # was rolled back off ``messages`` and the loop should re-issue the API # call against the newly-activated provider (#32421). restart_with_rebuilt_messages: bool = False + # A user correction cancelled the in-flight provider request. The outer + # loop must append a role-safe checkpoint + user message, rebuild the API + # payload, and retry the same logical iteration. + restart_with_redirected_messages: bool = False def __iter__(self): # Convenience for debugging / tests: iterate (name, value) pairs. diff --git a/run_agent.py b/run_agent.py index 7a37b9f3554b..4274c230e746 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2809,8 +2809,33 @@ class AIAgent: if session_has_running_agent: running_agent.interrupt(new_message.text) """ - self._interrupt_requested = True - self._interrupt_message = message + # A hard stop and redirect share one lock so /stop cannot race with an + # accepted correction and accidentally turn itself into a retry. + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + self._interrupt_requested = True + self._interrupt_message = message + self._pending_redirect = None + else: + self._interrupt_requested = True + self._interrupt_message = message + self._pending_redirect = None + + # Codex app-server owns its model/tool loop and watches a private + # interrupt event rather than Hermes' per-thread flag. + if getattr(self, "api_mode", None) == "codex_app_server": + _codex_session = getattr(self, "_codex_session", None) + _request_interrupt = getattr(_codex_session, "request_interrupt", None) + if callable(_request_interrupt): + try: + _request_interrupt() + except Exception: + logger.debug( + "Failed to interrupt Codex app-server turn", + exc_info=True, + ) + # A cron turn performs its API request on the conversation thread to # avoid the nested interrupt-worker deadlock. Unlike the normal worker # path, its client is registered here so this cross-thread interrupt can @@ -2863,10 +2888,29 @@ class AIAgent: if not self.quiet_mode: print("\n⚡ Interrupt requested" + (f": '{message[:40]}...'" if message and len(message) > 40 else f": '{message}'" if message else "")) - def clear_interrupt(self) -> None: - """Clear any pending interrupt request and the per-thread tool interrupt signal.""" - self._interrupt_requested = False - self._interrupt_message = None + def clear_interrupt(self, *, preserve_redirect: bool = False) -> bool: + """Clear the interrupt request and per-thread tool signal. + + ``preserve_redirect`` is used only by the conversation loop after it + intentionally cancels a model request to rebuild that same logical + turn. Public hard-stop paths keep the default and clear everything. + """ + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if preserve_redirect and not self._pending_redirect: + return False + self._interrupt_requested = False + self._interrupt_message = None + if not preserve_redirect: + self._pending_redirect = None + else: + if preserve_redirect and not getattr(self, "_pending_redirect", None): + return False + self._interrupt_requested = False + self._interrupt_message = None + if not preserve_redirect: + self._pending_redirect = None self._interrupt_thread_signal_pending = False if self._execution_thread_id is not None: _set_interrupt(False, self._execution_thread_id) @@ -2895,6 +2939,7 @@ class AIAgent: if _steer_lock is not None: with _steer_lock: self._pending_steer = None + return True def steer(self, text: str) -> bool: """ @@ -2932,6 +2977,118 @@ class AIAgent: self._pending_steer = cleaned return True + def redirect(self, text: str) -> bool: + """Redirect the active turn without converting it into a new task. + + During a normal Hermes model request this cancels only that request; + the conversation loop retains completed messages/tool results, records + the displayed partial reasoning as plain assistant context, appends the + correction as a real user message, and retries. During tool execution + it degrades to ``steer()`` so the tool can finish at a safe boundary. + Codex app-server has a native ``turn/steer`` operation and uses it + directly instead of cancelling. + + Returns ``False`` when there is no live turn or the text is empty, so + surfaces can fall back to their existing next-turn queue. + """ + if not text or not text.strip(): + return False + cleaned = text.strip() + + # Codex owns its internal reasoning/tool loop, so use its first-class + # active-turn steering protocol rather than interrupting the subprocess. + if getattr(self, "api_mode", None) == "codex_app_server": + _codex_session = getattr(self, "_codex_session", None) + _native_steer = getattr(_codex_session, "request_steer", None) + if callable(_native_steer): + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if self._interrupt_requested: + return False + elif self._interrupt_requested: + return False + try: + return bool(_native_steer(cleaned)) + except Exception: + logger.debug("Codex app-server turn/steer failed", exc_info=True) + return False + + # Never kill a tool merely to deliver conversational guidance. The + # existing steer drain puts it on the final tool result before the next + # model decision, including delegate_task children. + if getattr(self, "_executing_tools", False): + return self.steer(cleaned) + + _model_active = getattr(self, "_model_request_active", None) + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is None: + if _model_active is None or not _model_active.is_set(): + return False + existing = getattr(self, "_pending_redirect", None) + if self._interrupt_requested and not existing: + return False + self._pending_redirect = ( + f"{existing}\n\n[Additional user correction]\n{cleaned}" + if existing + else cleaned + ) + self._interrupt_requested = True + self._interrupt_message = None + else: + with _redirect_lock: + if _model_active is None or not _model_active.is_set(): + # The response completed before we acquired the state lock. + # Reject so the surface queues a new turn. + return False + if self._interrupt_requested and not self._pending_redirect: + return False + if self._pending_redirect: + self._pending_redirect = ( + f"{self._pending_redirect}\n\n" + f"[Additional user correction]\n{cleaned}" + ) + else: + self._pending_redirect = cleaned + self._interrupt_requested = True + self._interrupt_message = None + + # Interrupt only the model request. Do not fan out to tool workers or + # child agents as interrupt() does. + _execution_thread_id = getattr(self, "_execution_thread_id", None) + if _execution_thread_id is not None: + _set_interrupt(True, _execution_thread_id) + self._interrupt_thread_signal_pending = False + else: + self._interrupt_thread_signal_pending = True + _abort_active_request = getattr(self, "_active_request_abort", None) + if callable(_abort_active_request): + try: + _abort_active_request("redirect_abort") + except Exception: + logger.debug("Failed to abort request for redirect", exc_info=True) + return True + + def _has_pending_redirect(self) -> bool: + """Return whether an active-turn redirect is waiting to be applied.""" + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is None: + return bool(getattr(self, "_pending_redirect", None)) + with _redirect_lock: + return bool(self._pending_redirect) + + def _drain_pending_redirect(self) -> Optional[str]: + """Return and clear pending active-turn correction text.""" + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is None: + text = getattr(self, "_pending_redirect", None) + self._pending_redirect = None + return text + with _redirect_lock: + text = self._pending_redirect + self._pending_redirect = None + return text + def _drain_pending_steer(self) -> Optional[str]: """Return the pending steer text (if any) and clear the slot. @@ -4918,6 +5075,7 @@ class AIAgent: pass self._record_streamed_assistant_text(tail) self._current_streamed_assistant_text = "" + self._current_streamed_reasoning_text = "" def _record_streamed_assistant_text(self, text: str) -> None: """Accumulate visible assistant text emitted through stream callbacks.""" @@ -5258,6 +5416,15 @@ class AIAgent: cb(text) except Exception: pass + else: + # Only checkpoint reasoning that a surface actually displayed. + # show_reasoning=false leaves the callback unset, so hidden + # provider thinking never becomes visible transcript content. + if isinstance(text, str) and text: + self._current_streamed_reasoning_text = ( + getattr(self, "_current_streamed_reasoning_text", "") + + text + ) def _fire_tool_gen_started(self, tool_name: str) -> None: """Notify display layer that the model is generating tool call arguments. diff --git a/tests/agent/test_turn_retry_state.py b/tests/agent/test_turn_retry_state.py index 687a63f41899..89309f5cbef1 100644 --- a/tests/agent/test_turn_retry_state.py +++ b/tests/agent/test_turn_retry_state.py @@ -32,6 +32,7 @@ EXPECTED_FIELDS = { "restart_with_compressed_messages", "restart_with_length_continuation", "restart_with_rebuilt_messages", + "restart_with_redirected_messages", } diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 073bdd892c42..26d82cbcfdf6 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -12,6 +12,7 @@ import json import logging import re import threading +import time import uuid from logging.handlers import RotatingFileHandler from pathlib import Path @@ -4850,6 +4851,136 @@ class TestRunConversation: "content": "Sure, here's how to do it: first", } + def test_redirect_during_thinking_retries_same_turn_with_context(self, agent): + """A corrective follow-up keeps displayed reasoning and does not end the turn.""" + self._setup_agent(agent) + agent.reasoning_callback = lambda _text: None + final = _mock_response(content="Using Postgres instead.", finish_reason="stop") + requests = [] + persisted = [] + + def _fake_api_call(api_kwargs): + requests.append(api_kwargs) + if len(requests) == 1: + agent._fire_reasoning_delta("I should implement this with SQLite.") + assert agent.redirect("No, use Postgres instead.") is True + raise InterruptedError("redirect cancelled the first request") + return final + + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + patch.object( + agent, + "_persist_session", + side_effect=lambda messages, *_a, **_k: persisted.append( + [dict(message) for message in messages] + ), + ), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("Choose a database and implement it.") + + assert result["completed"] is True + assert result["interrupted"] is False + assert result["final_response"] == "Using Postgres instead." + assert len(requests) == 2 + + replay = requests[1]["messages"] + assert [m["role"] for m in replay[-3:]] == [ + "user", + "assistant", + "user", + ] + checkpoint = replay[-2]["content"] + assert "interrupted by a user correction" in checkpoint + assert "I should implement this with SQLite." in checkpoint + assert replay[-1]["content"] == "No, use Postgres instead." + assert agent._pending_redirect is None + assert any( + snapshot[-1].get("content") == "No, use Postgres instead." + and snapshot[-2].get("role") == "assistant" + for snapshot in persisted + if len(snapshot) >= 2 + ) + + def test_redirect_wins_race_with_response_completion(self, agent): + """If the provider returns as redirect lands, discard the stale answer.""" + self._setup_agent(agent) + stale = _mock_response(content="Using SQLite.", finish_reason="stop") + corrected = _mock_response(content="Using Postgres.", finish_reason="stop") + calls = 0 + + def _fake_api_call(_api_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + assert agent.redirect("Use Postgres instead.") is True + return stale + return corrected + + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("Choose a database.") + + assert calls == 2 + assert result["final_response"] == "Using Postgres." + assert all( + message.get("content") != "Using SQLite." + for message in result["messages"] + ) + + def test_redirect_from_input_thread_cancels_live_model_request(self, agent): + """Exercise the real cross-thread path used by CLI and gateways.""" + self._setup_agent(agent) + agent.reasoning_callback = lambda _text: None + entered = threading.Event() + results = {} + calls = 0 + final = _mock_response(content="Corrected answer.", finish_reason="stop") + + def _fake_api_call(_api_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + agent._fire_reasoning_delta("Following the original approach.") + entered.set() + deadline = time.time() + 2 + while not agent._interrupt_requested and time.time() < deadline: + time.sleep(0.01) + raise InterruptedError("request cancelled by redirect") + return final + + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + worker = threading.Thread( + target=lambda: results.update( + result=agent.run_conversation("Take the original approach.") + ) + ) + worker.start() + assert entered.wait(timeout=2) + assert agent.redirect("Use the corrected approach.") is True + worker.join(timeout=5) + + assert worker.is_alive() is False + assert calls == 2 + assert results["result"]["completed"] is True + assert results["result"]["final_response"] == "Corrected answer." + checkpoint = results["result"]["messages"][-3] + assert "Following the original approach." in checkpoint["content"] + assert results["result"]["messages"][-2]["content"] == ( + "Use the corrected approach." + ) + def test_interrupt_before_any_stream_keeps_sentinel(self, agent): """An interrupt with no streamed text falls back to the metadata sentinel.""" from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX diff --git a/tests/run_agent/test_steer.py b/tests/run_agent/test_steer.py index 99feb56343e7..ebf84c617d4b 100644 --- a/tests/run_agent/test_steer.py +++ b/tests/run_agent/test_steer.py @@ -23,6 +23,24 @@ def _bare_agent() -> AIAgent: agent = object.__new__(AIAgent) agent._pending_steer = None agent._pending_steer_lock = threading.Lock() + agent._pending_redirect = None + agent._pending_redirect_lock = threading.Lock() + agent._model_request_active = threading.Event() + agent._executing_tools = False + agent._execution_thread_id = None + agent._interrupt_thread_signal_pending = False + agent._interrupt_requested = False + agent._interrupt_message = None + agent._active_children = [] + agent._active_children_lock = threading.Lock() + agent._tool_worker_threads = None + agent._tool_worker_threads_lock = None + agent._current_streamed_reasoning_text = "" + agent._current_streamed_assistant_text = "" + agent._stream_needs_break = False + agent._strip_think_blocks = lambda content: content + agent.quiet_mode = True + agent.api_mode = "chat_completions" return agent @@ -72,6 +90,161 @@ class TestSteerDrain: assert agent._drain_pending_steer() is None +class TestActiveTurnRedirect: + def test_rejects_when_no_turn_is_active(self): + agent = _bare_agent() + assert agent.redirect("change course") is False + assert agent._pending_redirect is None + + def test_cancels_only_an_active_model_request(self): + agent = _bare_agent() + agent._model_request_active.set() + + assert agent.redirect("use Postgres") is True + assert agent._pending_redirect == "use Postgres" + assert agent._interrupt_requested is True + assert agent._interrupt_message is None + + def test_multiple_redirects_preserve_message_boundaries(self): + agent = _bare_agent() + agent._model_request_active.set() + + assert agent.redirect("first correction") is True + assert agent.redirect("second correction") is True + assert agent._pending_redirect == ( + "first correction\n\n" + "[Additional user correction]\n" + "second correction" + ) + + def test_hard_interrupt_wins_over_new_redirect(self): + agent = _bare_agent() + agent._model_request_active.set() + agent._interrupt_requested = True + + assert agent.redirect("too late") is False + assert agent._pending_redirect is None + + def test_hidden_reasoning_is_not_checkpointed(self): + agent = _bare_agent() + agent.reasoning_callback = None + agent._current_streamed_reasoning_text = "" + + agent._fire_reasoning_delta("private provider thinking") + + assert agent._current_streamed_reasoning_text == "" + + def test_response_completion_before_redirect_lock_rejects_correction(self): + agent = _bare_agent() + agent._model_request_active.set() + started = threading.Event() + outcome = {} + + def redirect(): + started.set() + outcome["accepted"] = agent.redirect("late correction") + + with agent._pending_redirect_lock: + worker = threading.Thread(target=redirect) + worker.start() + assert started.wait(timeout=1) + # Mirrors conversation_loop clearing the request-active marker + # under this same lock before redirect can commit its slot. + agent._model_request_active.clear() + worker.join(timeout=1) + + assert outcome["accepted"] is False + assert agent._pending_redirect is None + + def test_hard_stop_wins_concurrent_redirect(self): + agent = _bare_agent() + agent._model_request_active.set() + start = threading.Barrier(3) + outcome = {} + + def redirect(): + start.wait() + outcome["redirect"] = agent.redirect("change course") + + def hard_stop(): + start.wait() + agent.interrupt("stop requested") + + redirect_thread = threading.Thread(target=redirect) + stop_thread = threading.Thread(target=hard_stop) + redirect_thread.start() + stop_thread.start() + start.wait() + redirect_thread.join(timeout=1) + stop_thread.join(timeout=1) + + assert redirect_thread.is_alive() is False + assert stop_thread.is_alive() is False + assert agent._interrupt_requested is True + assert agent._interrupt_message == "stop requested" + assert agent._pending_redirect is None + + def test_codex_app_server_hard_stop_reaches_native_session(self): + agent = _bare_agent() + calls = [] + agent.api_mode = "codex_app_server" + agent._codex_session = type( + "_CodexSession", + (), + {"request_interrupt": lambda self: calls.append("interrupt")}, + )() + + agent.interrupt() + + assert calls == ["interrupt"] + + def test_codex_app_server_redirect_rejects_after_hard_stop(self): + agent = _bare_agent() + calls = [] + agent.api_mode = "codex_app_server" + agent._interrupt_requested = True + agent._codex_session = type( + "_CodexSession", + (), + {"request_steer": lambda self, text: calls.append(text) or True}, + )() + + assert agent.redirect("too late") is False + assert calls == [] + + def test_redirect_during_tool_execution_uses_safe_steer_boundary(self): + agent = _bare_agent() + agent._executing_tools = True + + assert agent.redirect("also check migrations") is True + assert agent._pending_redirect is None + assert agent._pending_steer == "also check migrations" + assert agent._interrupt_requested is False + + +class TestActiveTurnRedirectCheckpoint: + def test_assistant_tail_puts_correction_last(self): + from agent.conversation_loop import _apply_active_turn_redirect + + agent = _bare_agent() + agent._current_streamed_reasoning_text = "Shown reasoning." + agent._current_streamed_assistant_text = "Visible draft." + messages = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "committed assistant item"}, + ] + + _apply_active_turn_redirect(agent, messages, "Use Postgres instead.") + + assert [m["role"] for m in messages] == ["user", "assistant", "user"] + assert messages[-1]["role"] == "user" + assert messages[-1]["content"].endswith("Use Postgres instead.") + assert sum(1 for m in messages if m["role"] == "assistant") == 1 + assert "Shown reasoning." in messages[-1]["content"] + assert "Visible draft." in messages[-1]["content"] + assert "Context from the interrupted assistant response" in messages[-1]["content"] + + class TestSteerInjection: def test_appends_to_last_tool_result(self): agent = _bare_agent() @@ -196,10 +369,12 @@ class TestSteerClearedOnInterrupt: agent._tool_worker_threads_lock = None agent.steer("will be dropped") + agent._pending_redirect = "also drop this" assert agent._pending_steer == "will be dropped" agent.clear_interrupt() assert agent._pending_steer is None + assert agent._pending_redirect is None class TestPreApiCallSteerDrain: From f6d2ee4afc25dea1b4df64c5be89115eb432bde6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:39 -0400 Subject: [PATCH 2/6] feat(codex): honor redirect and hard stop in the app-server runtime The Codex app-server runtime bypasses the main conversation loop and drives its own subprocess turn, so it needs first-class hooks rather than the OpenAI-loop interrupt path. - `AIAgent.interrupt()` now forwards a hard stop to `CodexAppServerSession.request_interrupt()`, and `redirect()` uses Codex's native `turn/steer` protocol instead of cancelling the subprocess. - `run_turn()` no longer clears an interrupt that arrived during `ensure_started()`: a stop landing mid-startup is honored before `turn/start`, and the interrupt event is cleared on every exit path. - `run_codex_app_server_turn()` mirrors the loop finalizer's interrupt handoff (surface `interrupted` / `interrupt_message`, then `clear_interrupt()`) on both the normal and exception early-return paths, so a hard stop can't leave `_interrupt_requested` stale for the next turn. --- agent/codex_runtime.py | 34 +++++++++++++ agent/transports/codex_app_server_session.py | 47 ++++++++++++++++- tests/agent/test_codex_app_server_persist.py | 28 ++++++++++ .../test_codex_app_server_session.py | 51 ++++++++++++++----- 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 91c2af3e995f..59f9bac25a26 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -702,6 +702,16 @@ def run_codex_app_server_turn( except Exception: pass agent._codex_session = None + _user_interrupted = bool( + getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) + if _user_interrupted + else None + ) + if _user_interrupted: + agent.clear_interrupt() return { "final_response": ( f"Codex app-server turn failed: {exc}. " @@ -711,9 +721,27 @@ def run_codex_app_server_turn( "api_calls": 0, "completed": False, "partial": True, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": str(exc), } + # This runtime bypasses the normal conversation-loop finalizer. Mirror its + # interrupt handoff/cleanup so a hard stop cannot poison the next turn and a + # message-bearing compatibility interrupt can still be replayed by callers. + _user_interrupted = bool( + turn.interrupted and getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) if _user_interrupted else None + ) + if _user_interrupted: + agent.clear_interrupt() + # If the turn signalled the underlying client is wedged (deadline # blown, post-tool watchdog tripped, OAuth refresh died, subprocess # exited), retire the session so the next turn respawns codex @@ -819,6 +847,12 @@ def run_codex_app_server_turn( "api_calls": api_calls, "completed": not turn.interrupted and turn.error is None, "partial": turn.interrupted or turn.error is not None, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": turn.error, # The codex app-server runtime IS an early-return path that bypasses # conversation_loop, but we flush the projected assistant/tool messages diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 2e1cc64bdcbc..7954ecbf4d52 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -300,6 +300,8 @@ class CodexAppServerSession: self._client: Optional[CodexAppServerClient] = None self._thread_id: Optional[str] = None self._interrupt_event = threading.Event() + self._active_turn_id: Optional[str] = None + self._active_turn_lock = threading.Lock() # Pending file-change items, keyed by item id. Populated on # item/started for fileChange items; consumed by the approval # bridge when codex sends item/fileChange/requestApproval. The @@ -374,6 +376,8 @@ class CodexAppServerSession: if self._closed: return self._closed = True + with self._active_turn_lock: + self._active_turn_id = None if self._client is not None: try: self._client.close() @@ -395,6 +399,33 @@ class CodexAppServerSession: and unwind. Called by AIAgent's _interrupt_requested path.""" self._interrupt_event.set() + def request_steer(self, text: str) -> bool: + """Append user guidance to the active Codex turn via ``turn/steer``.""" + cleaned = str(text or "").strip() + if not cleaned: + return False + with self._active_turn_lock: + turn_id = self._active_turn_id + thread_id = self._thread_id + client = self._client + if not turn_id or not thread_id or client is None: + return False + try: + response = client.request( + "turn/steer", + { + "threadId": thread_id, + "input": [{"type": "text", "text": cleaned}], + "expectedTurnId": turn_id, + }, + timeout=10, + ) + except (CodexAppServerError, TimeoutError): + logger.debug("turn/steer rejected for active Codex turn", exc_info=True) + return False + accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None + return accepted_turn_id in {None, turn_id} + # ---------- diagnostics ---------- def _format_error_with_stderr( @@ -469,11 +500,18 @@ class CodexAppServerSession: # Subprocess almost certainly unhealthy — retire so the next # turn re-spawns cleanly. result.should_retire = True + self._interrupt_event.clear() return result assert self._client is not None and self._thread_id is not None result.thread_id = self._thread_id - self._interrupt_event.clear() + # Do not clear here: a hard stop can arrive while ensure_started() is + # spawning/initializing the subprocess. Honor it before launching a + # Codex turn instead of erasing the signal. + if self._interrupt_event.is_set(): + result.interrupted = True + self._interrupt_event.clear() + return result projector = CodexEventProjector() user_input_text = _coerce_turn_input_text(user_input) @@ -505,6 +543,7 @@ class CodexAppServerSession: result.error = self._format_error_with_stderr( "turn/start failed", exc ) + self._interrupt_event.clear() return result except TimeoutError as exc: # turn/start hanging is a strong signal the subprocess is wedged. @@ -514,9 +553,12 @@ class CodexAppServerSession: "turn/start timed out", exc ) result.should_retire = True + self._interrupt_event.clear() return result result.turn_id = (ts.get("turn") or {}).get("id") + with self._active_turn_lock: + self._active_turn_id = result.turn_id deadline = time.monotonic() + turn_timeout turn_complete = False # Post-tool watchdog state. last_tool_completion_at is set whenever @@ -741,6 +783,9 @@ class CodexAppServerSession: ) result.should_retire = True + with self._active_turn_lock: + self._active_turn_id = None + self._interrupt_event.clear() return result def compact_thread( diff --git a/tests/agent/test_codex_app_server_persist.py b/tests/agent/test_codex_app_server_persist.py index 001082e3f0ea..85d4a5757f7e 100644 --- a/tests/agent/test_codex_app_server_persist.py +++ b/tests/agent/test_codex_app_server_persist.py @@ -76,6 +76,34 @@ def test_codex_success_flushes_and_reports_persisted(): assert result["agent_persisted"] is True +def test_codex_user_interrupt_is_reported_and_cleared(): + agent = _make_agent(session_db=None) + turn = _make_turn() + turn.interrupted = True + turn.final_text = "" + agent._codex_session.run_turn.return_value = turn + agent._interrupt_requested = True + agent._interrupt_message = "new correction" + + def clear_interrupt(): + agent._interrupt_requested = False + agent._interrupt_message = None + + agent.clear_interrupt.side_effect = clear_interrupt + result = run_codex_app_server_turn( + agent, + user_message="hello", + original_user_message="hello", + messages=[{"role": "user", "content": "hello"}], + effective_task_id="task-1", + ) + + assert result["interrupted"] is True + assert result["interrupt_message"] == "new correction" + agent.clear_interrupt.assert_called_once_with() + assert agent._interrupt_requested is False + + def test_codex_turn_persists_each_message_exactly_once(): """The user turn (flushed at turn start) must not be duplicated; the projected assistant message must land once. Uses a real SessionDB and the diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index 5479c14ae21e..af850fc0f8d5 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -57,6 +57,8 @@ class FakeClient: return {"turn": {"id": "turn-fake-001"}} if method == "turn/interrupt": return {} + if method == "turn/steer": + return {"turnId": (params or {}).get("expectedTurnId")} return {} def notify(self, method: str, params=None): @@ -562,30 +564,53 @@ class TestRunTurn: assert r.should_retire is True assert r.final_text == "" - def test_interrupt_during_turn_issues_turn_interrupt(self): + def test_interrupt_during_startup_skips_turn_start(self): client = FakeClient() - # Don't queue turn/completed — the loop has to interrupt out - client.queue_notification( - "item/completed", - item={"type": "commandExecution", "id": "x", "command": "sleep 60", - "cwd": "/", "status": "inProgress", - "aggregatedOutput": None, "exitCode": None, - "commandActions": []}, - threadId="t", turnId="tu1", - ) s = make_session(client) s.ensure_started() - # Trip the interrupt before run_turn even consumes the notification. - # The loop will see interrupt set on its first iteration and bail. s.request_interrupt() r = s.run_turn("loop forever", turn_timeout=2.0) + + assert r.interrupted is True + assert not any(method == "turn/start" for method, _params in client.requests) + + def test_interrupt_after_turn_start_issues_turn_interrupt(self): + client = FakeClient() + s = make_session(client) + + def request_handler(method, params): + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + if method == "turn/start": + s.request_interrupt() + return {"turn": {"id": "turn-fake-001"}} + return {} + + client._request_handler = request_handler + r = s.run_turn("loop forever", turn_timeout=2.0) + assert r.interrupted is True - # turn/interrupt was requested with the right turnId assert any( method == "turn/interrupt" and params.get("turnId") == "turn-fake-001" for (method, params) in client.requests ) + def test_steer_appends_input_to_active_turn(self): + client = FakeClient() + s = make_session(client) + s.ensure_started() + with s._active_turn_lock: + s._active_turn_id = "turn-live-123" + + assert s.request_steer("Use Postgres instead") is True + method, params = client.requests[-1] + assert method == "turn/steer" + assert params == { + "threadId": "thread-fake-001", + "input": [{"type": "text", "text": "Use Postgres instead"}], + "expectedTurnId": "turn-live-123", + } + def test_deadline_exceeded_records_error(self): client = FakeClient() # No notifications and no completion → must hit deadline From 34d0de80e64a47cb1022d99b964d3755a3c2bd3d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:39 -0400 Subject: [PATCH 3/6] feat(surfaces): route busy-input corrections through active-turn redirect The default `busy_input_mode: interrupt` now redirects the live turn instead of hard-stopping it and re-queuing a fresh turn, wired consistently across every first-party surface via the shared core primitive. - CLI, gateway (busy + PRIORITY paths), TUI (`_handle_busy_submit`), desktop (`session.redirect` RPC), and ACP call `redirect()` when the agent advertises `_supports_active_turn_redirect`, and fall back to the proven interrupt + next-turn queue for older runtimes. - Redirect is gated to plain text with no attachments: captioned or attachment-bearing events (including adapters that classify unknown media as `TEXT`) stay queued so media is never dropped. - ACP `cancel()` records the interrupted prompt, sets its cancel event, and hard-stops the agent while holding `runtime_lock`, closing the cancel-then-correct ordering gap; connection I/O happens after the lock is released. - Desktop appends the correction as a real user transcript message so the live view matches the durable history after reload. - `/busy` help, onboarding hints, and the new `session.redirect` RPC describe the redirect behavior; `/stop` remains the hard stop. --- acp_adapter/server.py | 98 +++++++++++++++---- agent/onboarding.py | 13 +++ .../composer/hooks/use-composer-submit.ts | 6 +- .../hooks/use-prompt-actions/index.test.tsx | 42 +++++--- .../session/hooks/use-prompt-actions/index.ts | 31 +++--- apps/desktop/src/app/types.ts | 5 + cli.py | 44 ++++++--- gateway/run.py | 65 +++++++++++- hermes_cli/cli_commands_mixin.py | 6 +- tests/acp_adapter/test_acp_commands.py | 62 ++++++++++++ tests/gateway/test_busy_session_ack.py | 48 +++++++++ tests/test_tui_gateway_queue_on_busy.py | 88 ++++++++++++++++- tests/test_tui_gateway_server.py | 29 ++++++ tui_gateway/server.py | 98 +++++++++++++++++-- ui-tui/src/app/useSubmission.ts | 19 ++-- 15 files changed, 563 insertions(+), 91 deletions(-) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 266d587b0743..f664c47070e4 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -1218,12 +1218,19 @@ class HermesACPAgent(acp.Agent): with state.runtime_lock: if state.is_running and state.current_prompt_text: state.interrupted_prompt_text = state.current_prompt_text - state.cancel_event.set() - try: - if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): - state.agent.interrupt() - except Exception: - logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True) + # Publish cancellation and hard-stop the agent before another + # prompt can acquire this lock and mistake the turn for + # redirectable work. + state.cancel_event.set() + try: + if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): + state.agent.interrupt() + except Exception: + logger.debug( + "Failed to interrupt ACP session %s", + session_id, + exc_info=True, + ) logger.info("Cancelled session %s", session_id) async def fork_session( @@ -1352,6 +1359,26 @@ class HermesACPAgent(acp.Agent): elif rewrite_idle: user_text = steer_text user_content = steer_text + elif ( + text_only_prompt + and isinstance(user_content, str) + and not user_text.startswith("/") + ): + # Some ACP clients implement "stop and send" as two protocol calls: + # cancel the active prompt, then submit plain correction text. Keep + # the cancelled request attached so deictic follow-ups ("not that + # file") still have an explicit target. + interrupted_prompt = "" + with state.runtime_lock: + if not state.is_running and state.interrupted_prompt_text: + interrupted_prompt = state.interrupted_prompt_text + state.interrupted_prompt_text = "" + if interrupted_prompt: + user_text = ( + f"{interrupted_prompt}\n\n" + f"User correction/guidance after interrupt: {user_text}" + ) + user_content = user_text # Intercept slash commands — handle locally without calling the LLM. # Slash commands are text-only; if the client included images/resources, @@ -1366,23 +1393,54 @@ class HermesACPAgent(acp.Agent): await self._send_usage_update(state) return PromptResponse(stop_reason="end_turn") - # If Zed sends another regular prompt while the same ACP session is - # still running, queue it instead of racing two AIAgent loops against - # the same state.history. /steer and /queue are handled above and can - # land immediately. + # If the client sends another regular text prompt while this ACP session + # is running, route it through the core active-turn redirect. Rich media + # and older runtimes retain the proven next-turn queue fallback. + redirected = False + queued_depth: int | None = None with state.runtime_lock: if state.is_running: - queued_text = user_text or "[Image attachment]" - state.queued_prompts.append(queued_text) - depth = len(state.queued_prompts) - if self._conn: - update = acp.update_agent_message_text( - f"Queued for the next turn. ({depth} queued)" + if ( + text_only_prompt + and isinstance(user_content, str) + and getattr( + state.agent, + "_supports_active_turn_redirect", + False, ) - await self._conn.session_update(session_id, update) - return PromptResponse(stop_reason="end_turn") - state.is_running = True - state.current_prompt_text = user_text or "[Image attachment]" + is True + and hasattr(state.agent, "redirect") + ): + try: + redirected = bool(state.agent.redirect(user_content)) + except Exception: + logger.debug( + "ACP active-turn redirect failed for %s", + session_id, + exc_info=True, + ) + if not redirected: + queued_text = user_text or "[Image attachment]" + state.queued_prompts.append(queued_text) + queued_depth = len(state.queued_prompts) + else: + state.is_running = True + state.current_prompt_text = user_text or "[Image attachment]" + + if redirected: + if self._conn: + update = acp.update_agent_message_text( + "Redirected the active turn with your correction." + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") + if queued_depth is not None: + if self._conn: + update = acp.update_agent_message_text( + f"Queued for the next turn. ({queued_depth} queued)" + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") logger.info("Prompt on session %s: %s", session_id, user_text[:100]) diff --git a/agent/onboarding.py b/agent/onboarding.py index c29ea1529fb0..148fbcc9fdae 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -52,6 +52,13 @@ def busy_input_hint_gateway(mode: str) -> str: "Send `/busy interrupt` or `/busy queue` to change this, or " "`/busy status` to check. This notice won't appear again." ) + if mode == "redirect": + return ( + "💡 First-time tip — I redirected the current run using your message. " + "Completed work stays in context, and `/stop` still cancels the task. " + "Send `/busy queue` to wait for a separate turn, or `/busy status` " + "to check. This notice won't appear again." + ) return ( "💡 First-time tip — I just interrupted my current task to answer you. " "Send `/busy queue` to queue follow-ups for after the current task instead, " @@ -74,6 +81,12 @@ def busy_input_hint_cli(mode: str) -> str: "after the next tool call. Use /busy interrupt or /busy queue to " "change this. This tip only shows once." ) + if mode == "redirect": + return ( + "(tip) Your correction redirected the current run without discarding " + "completed work. Use /stop to cancel or /busy queue to wait for a " + "separate turn. This tip only shows once." + ) return ( "(tip) Your message interrupted the current run. " "Use /busy queue to queue messages for the next turn instead, " diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index adf44e34a8d7..01ff0ecf8e4b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -173,9 +173,9 @@ export function useComposerSubmit({ focusInput() } - // Steer the live turn (nudge without interrupting). Clears the draft up front - // for snappy feedback; if the gateway rejects (no live tool window) the words - // are re-queued so nothing is lost — same safety net as a plain queue. + // Redirect the live turn with a correction. The gateway either restarts the + // active model request with its displayed context or waits for the current + // tool boundary. If the turn already ended, queue the words instead. const steerDraft = () => { if (!onSteer || !canSteer) { return diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a06ee1294c08..6fb71e1b6c35 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -61,6 +61,8 @@ interface HarnessHandle { activeSessionIdRef: MutableRefObject cancelRun: () => Promise restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise + redirectPrompt: (text: string) => Promise + /** @deprecated Use `redirectPrompt`. */ steerPrompt: (text: string) => Promise submitText: (text: string, options?: SubmitTextOptions) => Promise } @@ -160,6 +162,8 @@ function Harness({ act(async () => actions.cancelRun(...args)) as Promise, restoreToMessage: (...args: Parameters) => act(async () => actions.restoreToMessage(...args)) as Promise, + redirectPrompt: (...args: Parameters) => + act(async () => actions.redirectPrompt(...args)) as Promise, steerPrompt: (...args: Parameters) => act(async () => actions.steerPrompt(...args)) as Promise, submitText: (...args: Parameters) => @@ -168,6 +172,7 @@ function Harness({ }, [ actions.cancelRun, actions.restoreToMessage, + actions.redirectPrompt, actions.steerPrompt, actions.submitText, activeSessionIdRef, @@ -703,32 +708,41 @@ describe('usePromptActions submit / queue drain semantics', () => { }) }) -describe('usePromptActions steerPrompt', () => { +describe('usePromptActions redirectPrompt', () => { afterEach(() => { cleanup() vi.restoreAllMocks() }) - it('injects the trimmed text via session.steer and reports acceptance on a queued status', async () => { - const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) + it('redirects the live turn with trimmed correction text', async () => { + const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never) let handle: HarnessHandle | null = null + const capturedStates: Record[] = [] await actRender( - (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + (handle = h)} + onSeedState={state => capturedStates.push(state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> ) - const accepted = await handle!.steerPrompt(' nudge the run ') + const accepted = await handle!.redirectPrompt(' nudge the run ') expect(accepted).toBe(true) - // Steer never starts a turn — it rides the live run via session.steer only. - expect(requestGateway).toHaveBeenCalledWith('session.steer', { + expect(requestGateway).toHaveBeenCalledWith('session.redirect', { session_id: RUNTIME_SESSION_ID, text: 'nudge the run' }) expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything()) + expect((capturedStates.at(-1)?.messages as unknown[]).at(-1)).toMatchObject({ + role: 'user', + parts: [{ type: 'text', text: 'nudge the run' }] + }) }) - it('reports rejection (so the caller queues) when the gateway has no live tool window', async () => { + it('reports rejection so the caller queues when the turn already ended', async () => { const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never) let handle: HarnessHandle | null = null @@ -736,12 +750,12 @@ describe('usePromptActions steerPrompt', () => { (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - expect(await handle!.steerPrompt('too late')).toBe(false) + expect(await handle!.redirectPrompt('too late')).toBe(false) }) - it('reports rejection (never throws) when the steer RPC errors', async () => { + it('reports rejection without throwing when the redirect RPC errors', async () => { const requestGateway = vi.fn(async () => { - throw new Error('agent does not support steer') + throw new Error('agent does not support redirect') }) let handle: HarnessHandle | null = null @@ -749,18 +763,18 @@ describe('usePromptActions steerPrompt', () => { (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - expect(await handle!.steerPrompt('boom')).toBe(false) + expect(await handle!.redirectPrompt('boom')).toBe(false) }) it('skips the RPC entirely for empty text', async () => { - const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) + const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never) let handle: HarnessHandle | null = null await actRender( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - expect(await handle!.steerPrompt(' ')).toBe(false) + expect(await handle!.redirectPrompt(' ')).toBe(false) expect(requestGateway).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1fde9070d4a3..852e822b1896 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -41,7 +41,7 @@ import type { HandoffRequestResponse, HandoffStateResponse, ImageAttachResponse, - SessionSteerResponse + SessionRedirectResponse } from '../../../types' import { @@ -599,11 +599,11 @@ export function usePromptActions({ } }, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState]) - // Steer = nudge the live turn without interrupting: the gateway appends the - // text to the next tool result so the model reads it on its next iteration - // (desktop parity with `/steer`). Returns false on reject (no live tool - // window) so the caller can fall back to queueing the words for the next turn. - const steerPrompt = useCallback( + // The desktop steering action is an immediate correction: the core cancels + // model generation and rebuilds the live turn with displayed reasoning and + // completed work intact. During a tool it waits for the safe result boundary. + // Returns false when the turn raced to completion so the composer can queue. + const redirectPrompt = useCallback( async (rawText: string): Promise => { const text = sanitizeComposerInput(rawText).trim() const sessionId = activeSessionId || activeSessionIdRef.current @@ -613,14 +613,17 @@ export function usePromptActions({ } try { - const result = await requestGateway('session.steer', { session_id: sessionId, text }) + const result = await requestGateway('session.redirect', { + session_id: sessionId, + text + }) - if (result?.status === 'queued') { + if (result?.status === 'redirected') { triggerHaptic('submit') - // Inline note (not a toast) so the nudge lives in the transcript next - // to the turn it steered. The `steer:` prefix is rendered as a codicon - // row by SystemMessage (see STEER_NOTE_RE), same style as slash output. - appendSessionTextMessage(sessionId, 'system', `steer:${text}`) + // Match the durable core transcript: the correction is a real user + // message after the interrupted assistant checkpoint, not a system + // note that changes role after reload. + appendSessionTextMessage(sessionId, 'user', text) return true } @@ -806,7 +809,9 @@ export function usePromptActions({ handoffSession, reloadFromMessage, restoreToMessage, - steerPrompt, + redirectPrompt, + /** @deprecated Use `redirectPrompt` — this is an active-turn redirect, not tool steer. */ + steerPrompt: redirectPrompt, submitText, transcribeVoiceAudio } diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 3f8da4144334..a9d09165c763 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -60,6 +60,11 @@ export interface SessionSteerResponse { text?: string } +export interface SessionRedirectResponse { + status?: 'redirected' | 'rejected' + text?: string +} + export interface SessionTitleResponse { title?: string // True when the session row isn't persisted yet and the title was queued diff --git a/cli.py b/cli.py index cac922c850b0..b9641e53ee20 100644 --- a/cli.py +++ b/cli.py @@ -3816,7 +3816,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): enabled=CLI_CONFIG["display"].get("persistent_output", True), max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200), ) - # busy_input_mode: "interrupt" (Enter interrupts current run), + # busy_input_mode: "interrupt" (Enter redirects current run), # "queue" (Enter queues for next turn), or "steer" (Enter injects # mid-run via /steer, arriving after the next tool call). _bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower() @@ -13479,6 +13479,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): payload = (text, images) if images else text if self._agent_running and not (text and _looks_like_slash_command(text)): _effective_mode = self.busy_input_mode + redirected = False if _effective_mode == "steer": # Route Enter through /steer — inject mid-run after the # next tool call. Images can't ride along (steer only @@ -13506,15 +13507,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}") elif _effective_mode == "interrupt": - self._interrupt_queue.put(payload) - # Debug: log to file when message enters interrupt queue - try: - _dbg = _hermes_home / "interrupt_debug.log" - with open(_dbg, "a", encoding="utf-8") as _f: - _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " - f"agent_running={self._agent_running}\n") - except Exception: - pass + if not images and text: + try: + if ( + self.agent is not None + and getattr( + self.agent, + "_supports_active_turn_redirect", + False, + ) + is True + and hasattr(self.agent, "redirect") + ): + redirected = bool(self.agent.redirect(text)) + except Exception: + redirected = False + if redirected: + preview = text[:80] + ("..." if len(text) > 80 else "") + _cprint(f" {_ACCENT}↪ Redirected current turn: '{preview}'{_RST}") + else: + # Compatibility path for older agents, multimodal + # follow-ups, or a turn that finished in the race. + self._interrupt_queue.put(payload) + try: + _dbg = _hermes_home / "interrupt_debug.log" + with open(_dbg, "a", encoding="utf-8") as _f: + _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " + f"agent_running={self._agent_running}\n") + except Exception: + pass # First-touch onboarding: on the very first busy-while-running # event for this install, print a one-line tip explaining the # /busy knob. Flag persists to config.yaml and never fires @@ -13528,7 +13549,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): mark_seen, ) if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG): - _cprint(f" {_DIM}{busy_input_hint_cli(self.busy_input_mode)}{_RST}") + _hint_mode = "redirect" if redirected else _effective_mode + _cprint(f" {_DIM}{busy_input_hint_cli(_hint_mode)}{_RST}") mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True except Exception: diff --git a/gateway/run.py b/gateway/run.py index 42dd61e02776..7653ffb1e9b8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6030,10 +6030,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) effective_mode = "queue" steered = False + redirected = False if effective_mode == "steer": steer_text = (event.text or "").strip() can_steer = ( steer_text + and event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types and running_agent is not None and running_agent is not _AGENT_PENDING_SENTINEL and hasattr(running_agent, "steer") @@ -6047,6 +6051,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not steered: # Fall back to queue (merge into pending messages, no interrupt) effective_mode = "queue" + elif ( + effective_mode == "interrupt" + and event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types + and running_agent is not None + and running_agent is not _AGENT_PENDING_SENTINEL + and getattr(running_agent, "_supports_active_turn_redirect", False) is True + and hasattr(running_agent, "redirect") + ): + try: + redirected = bool(running_agent.redirect((event.text or "").strip())) + except Exception as exc: + logger.warning("Gateway redirect failed for session %s: %s", session_key, exc) + redirected = False # Store the message so it's processed as the next turn after the # current run finishes (or is interrupted). Skip this for a @@ -6063,16 +6082,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # turn (#43066 sub-bug 2). The FIFO path gives each text its own # turn in arrival order while still preserving photo-burst / album # merge semantics for media. - if not steered: + if not steered and not redirected: self._queue_or_replace_pending_event(session_key, event) is_queue_mode = effective_mode == "queue" is_steer_mode = effective_mode == "steer" + is_redirect_mode = effective_mode == "interrupt" and redirected # If not in queue/steer mode, interrupt the running agent immediately. # This aborts in-flight tool calls and causes the agent loop to exit # at the next check point. - if effective_mode == "interrupt" and running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + if ( + effective_mode == "interrupt" + and not redirected + and running_agent + and running_agent is not _AGENT_PENDING_SENTINEL + ): try: _interrupt_text = event.text _media_urls = getattr(event, "media_urls", None) or [] @@ -6170,6 +6195,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew f"⏩ Steered into current run{status_detail}. " f"Your message arrives after the next tool call." ) + elif is_redirect_mode: + message = ( + f"↪ Redirected current run{status_detail}. " + f"I'll adjust using your correction." + ) elif is_queue_mode and demoted_for_subagents: # #30170 — explain the demotion so the user knows their # follow-up didn't accidentally kill the subagent and @@ -6211,6 +6241,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _hint_mode = "steer" elif is_queue_mode: _hint_mode = "queue" + elif is_redirect_mode: + _hint_mode = "redirect" else: _hint_mode = "interrupt" message = ( @@ -10952,7 +10984,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # is empty, the agent lacks steer(), or steer() rejects. steer_text = (event.text or "").strip() steered = False - if steer_text and hasattr(running_agent, "steer"): + if ( + event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types + and steer_text + and hasattr(running_agent, "steer") + ): try: steered = bool(running_agent.steer(steer_text)) except Exception as exc: @@ -10996,6 +11034,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) self._queue_or_replace_pending_event(_quick_key, event) return None + # Text-only corrections redirect the live turn (preserving + # displayed context) when the runtime supports it; media/voice and + # older runtimes fall back to the proven interrupt path below. + if ( + event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types + and getattr(running_agent, "_supports_active_turn_redirect", False) + is True + and hasattr(running_agent, "redirect") + ): + try: + if running_agent.redirect((event.text or "").strip()): + logger.debug("PRIORITY redirect for session %s", _quick_key) + return None + except Exception as exc: + logger.warning( + "PRIORITY redirect failed for session %s: %s", + _quick_key, + exc, + ) logger.debug("PRIORITY interrupt for session %s", _quick_key) _interrupt_text = event.text _media_urls = getattr(event, "media_urls", None) or [] diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 4c98e70dbc66..f28c07602312 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2620,7 +2620,7 @@ class CLICommandsMixin: /busy status Show current busy input mode /busy queue Queue input for the next turn instead of interrupting /busy steer Inject Enter mid-run via /steer (after next tool call) - /busy interrupt Interrupt the current run on Enter (default) + /busy interrupt Redirect the current run on Enter (default) """ from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value parts = cmd.strip().split(maxsplit=1) @@ -2631,7 +2631,7 @@ class CLICommandsMixin: elif self.busy_input_mode == "steer": _behavior = "steers into current run (after next tool call)" else: - _behavior = "interrupts current run" + _behavior = "redirects current run immediately" _cprint(f" {_DIM}Enter while busy: {_behavior}{_RST}") _cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}") return @@ -2649,7 +2649,7 @@ class CLICommandsMixin: elif arg == "steer": behavior = "Enter will steer your message into the current run (after the next tool call)." else: - behavior = "Enter will interrupt the current run while Hermes is busy." + behavior = "Enter will redirect the current run while Hermes is busy; /stop still cancels it." _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (saved to config){_RST}") _cprint(f" {_DIM}{behavior}{_RST}") else: diff --git a/tests/acp_adapter/test_acp_commands.py b/tests/acp_adapter/test_acp_commands.py index 4a95367a6ba5..4f8ca69ed85e 100644 --- a/tests/acp_adapter/test_acp_commands.py +++ b/tests/acp_adapter/test_acp_commands.py @@ -16,13 +16,19 @@ class FakeAgent: self.disabled_toolsets = [] self.tools = [] self.valid_tool_names = set() + self._supports_active_turn_redirect = True self.steers = [] + self.redirects = [] self.runs = [] def steer(self, text): self.steers.append(text) return True + def redirect(self, text): + self.redirects.append(text) + return True + def run_conversation(self, *, user_message, conversation_history, task_id, **kwargs): self.runs.append(user_message) messages = list(conversation_history or []) @@ -147,6 +153,62 @@ async def test_acp_steer_after_zed_interrupt_replays_interrupted_prompt_with_gui assert state.interrupted_prompt_text == "" +@pytest.mark.asyncio +async def test_acp_plain_correction_redirects_running_turn(): + acp_agent, state, fake, _conn = make_agent_and_state() + state.is_running = True + + response = await acp_agent.prompt( + session_id=state.session_id, + prompt=[TextContentBlock(type="text", text="No, use Postgres instead")], + ) + + assert response.stop_reason == "end_turn" + assert fake.redirects == ["No, use Postgres instead"] + assert state.queued_prompts == [] + assert fake.runs == [] + + +@pytest.mark.asyncio +async def test_acp_plain_correction_after_cancel_replays_original_prompt(): + acp_agent, state, fake, _conn = make_agent_and_state() + state.interrupted_prompt_text = "implement it with SQLite" + + response = await acp_agent.prompt( + session_id=state.session_id, + prompt=[TextContentBlock(type="text", text="No, use Postgres instead")], + ) + + assert response.stop_reason == "end_turn" + assert fake.runs == [ + "implement it with SQLite\n\n" + "User correction/guidance after interrupt: No, use Postgres instead" + ] + assert state.interrupted_prompt_text == "" + + +@pytest.mark.asyncio +async def test_acp_cancel_publishes_hard_stop_while_holding_runtime_lock(): + acp_agent, state, fake, _conn = make_agent_and_state() + state.is_running = True + state.current_prompt_text = "original request" + observed = {} + + def interrupt(): + acquired = state.runtime_lock.acquire(blocking=False) + observed["lock_held"] = not acquired + if acquired: + state.runtime_lock.release() + + fake.interrupt = interrupt + + await acp_agent.cancel(state.session_id) + + assert observed["lock_held"] is True + assert state.cancel_event.is_set() + assert state.interrupted_prompt_text == "original request" + + @pytest.mark.asyncio async def test_acp_steer_on_idle_session_runs_as_regular_prompt(): # /steer on an idle session (no running turn, nothing to salvage) should diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 66c4672f21c3..8b8598757992 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -211,6 +211,54 @@ class TestBusySessionAck: # Verify agent interrupt was called agent.interrupt.assert_called_once_with("Are you working?") + @pytest.mark.asyncio + async def test_interrupt_mode_redirects_capable_core_agent(self): + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + event = _make_event(text="No, use Postgres") + sk = build_session_key(event.source) + + agent = MagicMock() + agent._supports_active_turn_redirect = True + agent.redirect.return_value = True + agent._active_children = [] + agent.get_activity_summary.return_value = {} + runner._running_agents[sk] = agent + runner.adapters[event.source.platform] = adapter + + assert await runner._handle_active_session_busy_message(event, sk) is True + + agent.redirect.assert_called_once_with("No, use Postgres") + agent.interrupt.assert_not_called() + assert sk not in adapter._pending_messages + content = adapter._send_with_retry.call_args.kwargs.get("content", "") + assert "Redirected current run" in content + + @pytest.mark.asyncio + async def test_text_event_with_attachment_is_queued_not_redirected(self): + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + event = _make_event(text="use this attachment") + # QQBot and other adapters may retain unknown attachment MIME types on + # a TEXT event, so message_type alone is not a safe redirect gate. + event.media_urls = ["https://example.invalid/attachment.bin"] + event.media_types = ["application/octet-stream"] + sk = build_session_key(event.source) + + agent = MagicMock() + agent._supports_active_turn_redirect = True + agent._active_children = [] + runner._running_agents[sk] = agent + runner.adapters[event.source.platform] = adapter + + assert await runner._handle_active_session_busy_message(event, sk) is True + + agent.redirect.assert_not_called() + assert adapter._pending_messages[sk] is event + assert adapter._pending_messages[sk].media_urls == event.media_urls + @pytest.mark.asyncio async def test_queue_mode_suppresses_interrupt_and_updates_ack(self): """When busy_input_mode is 'queue', message is queued WITHOUT interrupt.""" diff --git a/tests/test_tui_gateway_queue_on_busy.py b/tests/test_tui_gateway_queue_on_busy.py index 804a3b5505cf..0543d7e8b6b2 100644 --- a/tests/test_tui_gateway_queue_on_busy.py +++ b/tests/test_tui_gateway_queue_on_busy.py @@ -1,12 +1,12 @@ -"""A prompt that lands mid-turn is interrupted + queued, never dropped. +"""A prompt that lands mid-turn is redirected or queued, never dropped. Before this, ``prompt.submit`` on a running session returned ``session busy``, forcing clients into a deadline-bounded busy-retry. When turn teardown outlived the deadline — e.g. a slow, non-interruptible tool (``web_search``) still running when the user hit stop — the resubmitted message was silently dropped ("it just doesn't listen"). The gateway now applies the ``busy_input_mode`` -policy: interrupt the live turn (default) and queue the message to run as the -next turn, drained in ``run``'s tail. +policy: redirect the live turn by default, with the legacy interrupt + queue +path retained as a compatibility fallback. """ import threading @@ -49,7 +49,26 @@ def test_enqueue_merges_second_arrival_losslessly(): # ── _handle_busy_submit (policy) ─────────────────────────────────────────── -def test_busy_interrupt_mode_interrupts_and_queues(monkeypatch): +def test_busy_interrupt_mode_redirects_active_turn(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + seen = [] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: seen.append(text) or True, + interrupt=lambda *a, **k: (_ for _ in ()).throw( + AssertionError("redirect must not hard-interrupt") + ), + ) + session = _session(agent=agent, running=True) + + resp = server._handle_busy_submit("r1", "sid", session, "redirect", "ws-1") + + assert resp["result"]["status"] == "redirected" + assert seen == ["redirect"] + assert session.get("queued_prompt") is None + + +def test_busy_interrupt_mode_falls_back_for_legacy_agent(monkeypatch): monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") calls = {"interrupt": 0} agent = types.SimpleNamespace(interrupt=lambda *a, **k: calls.__setitem__("interrupt", calls["interrupt"] + 1)) @@ -134,6 +153,67 @@ def test_busy_helper_retries_when_turn_finished(monkeypatch): assert session.get("queued_prompt") is None +def test_busy_interrupt_mode_normalizes_rich_text_before_redirect(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + seen = [] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: seen.append(text) or True, + interrupt=lambda *a, **k: None, + ) + session = _session(agent=agent, running=True) + rich = [{"type": "text", "text": " redirect me "}] + + resp = server._handle_busy_submit( + "r1", + "sid", + session, + rich, + "ws-1", + ) + + assert resp["result"]["status"] == "redirected" + assert seen == ["redirect me"] + assert session.get("queued_prompt") is None + + +def test_busy_queue_fallback_preserves_original_structured_text(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + rich = [{"type": "text", "text": " keep me "}] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: False, + interrupt=lambda *a, **k: None, + ) + session = _session(agent=agent, running=True) + + resp = server._handle_busy_submit("r1", "sid", session, rich, "ws-1") + + assert resp["result"]["status"] == "queued" + assert session["queued_prompt"]["text"] == rich + + +def test_busy_interrupt_mode_queues_multimodal_payload_instead_of_redirect(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + seen = [] + rich = [ + {"type": "text", "text": "caption"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: seen.append(text) or True, + interrupt=lambda *a, **k: None, + ) + session = _session(agent=agent, running=True) + + resp = server._handle_busy_submit("r1", "sid", session, rich, "ws-1") + + assert resp["result"]["status"] == "queued" + assert seen == [] + assert session["queued_prompt"]["text"] == rich + + # ── _drain_queued_prompt ─────────────────────────────────────────────────── def test_drain_fires_queued_prompt_and_claims_running(monkeypatch): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 125d3eb584e1..48fa63c7b821 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6835,6 +6835,35 @@ def test_session_steer_errors_when_agent_has_no_steer_method(): assert resp["error"]["code"] == 4010 +def test_session_redirect_calls_capable_core_agent(monkeypatch): + calls = [] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: calls.append(text) or True, + ) + session = _session(agent=agent) + server._sessions["sid"] = session + try: + before = session.get("last_active") + resp = server.handle_request( + { + "id": "1", + "method": "session.redirect", + "params": {"session_id": "sid", "text": "use Postgres"}, + } + ) + finally: + server._sessions.pop("sid", None) + + assert resp["result"] == { + "status": "redirected", + "text": "use Postgres", + } + assert calls == ["use Postgres"] + assert session.get("last_active") is not None + assert before is None or session["last_active"] >= before + + def test_session_info_includes_mcp_servers(monkeypatch): fake_status = [ {"name": "github", "transport": "http", "tools": 12, "connected": True}, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03d8492d932c..15ed6b4a7ad5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5553,6 +5553,38 @@ def _coerce_message_text(content: Any) -> str: return str(content) +_TEXT_ONLY_BUSY_PART_KINDS = frozenset({"text", "input_text", "output_text"}) + + +def _is_text_only_busy_payload(content: Any) -> bool: + """True when a busy submit carries only plain text, not attachments/media.""" + if content is None: + return False + if isinstance(content, (str, int, float)): + return True + if isinstance(content, list): + if not content: + return False + for part in content: + if isinstance(part, str): + continue + if not isinstance(part, dict): + return False + kind = part.get("type") + if kind in _TEXT_ONLY_BUSY_PART_KINDS: + continue + if kind is None and isinstance(part.get("text"), str): + continue + return False + return True + if isinstance(content, dict): + kind = content.get("type") + if kind in _TEXT_ONLY_BUSY_PART_KINDS: + return True + return kind is None and isinstance(content.get("text"), str) + return False + + def _history_to_messages(history: list[dict]) -> list[dict]: messages = [] tool_call_args = {} @@ -5761,15 +5793,13 @@ def _handle_busy_submit( a turn is in flight, instead of rejecting it with ``session busy``. The old rejection forced clients into a deadline-bounded busy-retry that - silently dropped the send when turn teardown outlived the deadline (e.g. a - slow, non-interruptible tool like ``web_search`` running when the user hits - stop). The message is instead queued to run as the next turn — and, for the - default ``interrupt`` policy, the live turn is interrupted so it winds down - promptly. Drained in ``run``'s tail (see ``_run_prompt_submit``). + silently dropped the send when turn teardown outlived the deadline. The + default policy now redirects a capable core agent in place; older agents + retain the proven interrupt-and-queue path drained from ``run``'s tail. - Modes: ``interrupt`` (default) → interrupt + queue; ``queue`` → queue - without interrupting; ``steer`` → inject into the live turn if accepted, - else queue. + Modes: ``interrupt`` (default) → redirect the live turn, falling back to + hard interrupt + queue for older agents; ``queue`` → queue without + interrupting; ``steer`` → inject after the current atomic action. """ mode = _load_busy_input_mode() agent = session.get("agent") @@ -5778,14 +5808,34 @@ def _handle_busy_submit( # The turn ended between prompt.submit's first busy check and this # helper. Let the caller retry and claim the now-idle session. return None - if mode == "steer" and agent is not None and hasattr(agent, "steer"): + text_only = _is_text_only_busy_payload(text) + plain_text = _coerce_message_text(text).strip() if text_only else "" + if mode == "steer" and text_only and plain_text and agent is not None and hasattr(agent, "steer"): try: - if agent.steer(text): + if agent.steer(plain_text): with session["history_lock"]: session["last_active"] = time.time() return _ok(rid, {"status": "steered"}) except Exception: pass # fall through to queue + # Text-only corrections redirect the live turn in place when the runtime + # supports it; media/attachment payloads and older agents fall through to + # the proven interrupt + queue path below. + if ( + mode == "interrupt" + and text_only + and plain_text + and agent is not None + and getattr(agent, "_supports_active_turn_redirect", False) is True + and hasattr(agent, "redirect") + ): + try: + if agent.redirect(plain_text): + with session["history_lock"]: + session["last_active"] = time.time() + return _ok(rid, {"status": "redirected"}) + except Exception: + pass # preserve the proven interrupt + queue fallback below # Queue before asking the live turn to stop. In particular, never call a # provider or compute-host method while holding history_lock: an interrupt # can wait behind the very operation it is trying to cancel. @@ -9585,6 +9635,34 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "queued" if accepted else "rejected", "text": text}) +@method("session.redirect") +def _(rid, params: dict) -> dict: + """Redirect the active model turn while preserving valid work/context.""" + text = (params.get("text") or "").strip() + if not text: + return _err(rid, 4002, "text is required") + session, err = _sess_nowait(params, rid) + if err: + return err + agent = session.get("agent") + if ( + agent is None + or getattr(agent, "_supports_active_turn_redirect", False) is not True + or not hasattr(agent, "redirect") + ): + return _err(rid, 4010, "agent does not support active-turn redirect") + try: + accepted = agent.redirect(text) + except Exception as exc: + return _err(rid, 5000, f"redirect failed: {exc}") + if accepted: + session["last_active"] = time.time() + return _ok( + rid, + {"status": "redirected" if accepted else "rejected", "text": text}, + ) + + @method("terminal.resize") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index a5c484cc288c..a70b1fd7390d 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -158,9 +158,9 @@ export function useSubmission(opts: UseSubmissionOptions) { // - 'steer' : inject into the current turn via session.steer; falls // back to queue when steer is rejected (no agent / no // tool window). - // - 'interrupt' (default): queue the text + interrupt with `keepBusy`; the - // busy→false settle edge drains it once (desktop parity). - // No optimistic send → no duplicate bubble / race note. + // - 'interrupt' (default): submit immediately; the backend redirects the + // active model request (or safely steers after a tool), + // with legacy interrupt + queue as its compatibility path. // // `opts.fallbackToFront` re-inserts at the queue head (queue-edit picks keep // their position); the mainline submit path appends. @@ -201,14 +201,13 @@ export function useSubmission(opts: UseSubmissionOptions) { return } - // 'interrupt': queue + interrupt(keepBusy); the settle edge drains it once. - enqueueText() - - if (live.sid) { - turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: true }) - } + // The gateway owns the atomic redirect decision because it knows whether + // the agent is in model generation, tool execution, or an older runtime. + // Reuse the normal submit pipeline so the correction gets its user bubble + // and file-drop interpolation exactly once. + send(full) }, - [appendMessage, composerActions, composerRefs, gw, sys] + [composerActions, composerRefs, gw, send, sys] ) const dispatchSubmission = useCallback( From 79b047820f8e7e8527b3293981d9d0996b36dcab Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:39 -0400 Subject: [PATCH 4/6] docs: describe active-turn redirect busy-input behavior Update the CLI and messaging guides so the default `interrupt` mode reflects the new behavior: a follow-up redirects the active turn (preserving displayed reasoning and completed work, letting running tools finish at a safe boundary) rather than hard-stopping it, with `/stop` still the explicit hard stop. --- website/docs/user-guide/cli.md | 16 ++++++++-------- website/docs/user-guide/messaging/index.md | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index f786e5a42f91..ca10b145847d 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -241,14 +241,14 @@ Most terminals send the same byte sequence for `Enter` and `Shift+Enter` by defa Where the terminal cannot distinguish them, `Alt+Enter` and `Ctrl+J` continue to work everywhere. **On Windows Terminal specifically, `Alt+Enter` is captured by the terminal (toggles fullscreen) and never reaches Hermes — use `Ctrl+Enter` (delivered as `Ctrl+J`) or `Ctrl+J` directly for a newline.** -## Interrupting the Agent +## Redirecting the Agent Mid-Turn -You can interrupt the agent at any point: +While the agent is working, you can send a correction without starting a new turn: -- **Type a new message + Enter** while the agent is working — it interrupts and processes your new instructions +- **Type a new message + Enter** — redirects the active turn using your correction - **`Ctrl+C`** — interrupt the current operation (press twice within 2s to force exit) -- In-progress terminal commands are killed immediately (SIGTERM, then SIGKILL after 1s) -- Multiple messages typed during interrupt are combined into one prompt +- Completed tool work and reasoning already shown stay in context +- A running tool reaches its safe boundary before the correction is applied ### Busy Input Mode @@ -256,7 +256,7 @@ The `display.busy_input_mode` config key controls what happens when you press En | Mode | Behavior | |------|----------| -| `"interrupt"` (default) | Your message interrupts the current operation and is processed immediately | +| `"interrupt"` (default) | Your message redirects the active turn. Model generation restarts with displayed reasoning and completed work preserved; running tools finish first | | `"queue"` | Your message is silently queued and sent as the next turn after the agent finishes | | `"steer"` | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call — no interrupt, no new turn | @@ -266,7 +266,7 @@ display: busy_input_mode: "steer" # or "queue" or "interrupt" (default) ``` -`"queue"` mode is useful when you want to prepare follow-up messages without accidentally canceling in-flight work. `"steer"` mode is useful when you want to redirect the agent mid-task without interrupting — e.g. "actually, also check the tests" while it's still editing code. Unknown values fall back to `"interrupt"`. +`"queue"` mode prepares a separate follow-up turn. `"steer"` always waits for the next tool-result boundary. The default `"interrupt"` mode responds sooner during model generation while avoiding cancellation of a running tool. Use `/stop` when you want to cancel the turn and its foreground work. Unknown values fall back to `"interrupt"`. `"steer"` has two automatic fallbacks: if the agent hasn't started yet, or if images are attached, the message falls back to `"queue"` behavior so nothing is lost. @@ -280,7 +280,7 @@ You can also change it inside the CLI: ``` :::tip First-touch hint -The very first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob (`"(tip) Your message interrupted the current run…"`). It only fires once per install — a flag in `config.yaml` under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. +The first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob. It only fires once per install; `onboarding.seen.busy_input_prompt` in `config.yaml` records that it was shown. Delete that key to see the tip again. ::: ### Suspending to Background diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 88f57010064b..da107c4ae613 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -352,18 +352,18 @@ gateway: Use `/whoami` from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. See the [Telegram](/user-guide/messaging/telegram#slash-command-access-control) and [Discord](/user-guide/messaging/discord#slash-command-access-control) pages for platform-specific examples. -## Interrupting the Agent +## Redirecting the Agent -Send any message while the agent is working to interrupt it. Key behaviors: +Send a message while the agent is working to correct the active turn: -- **In-progress terminal commands are killed immediately** (SIGTERM, then SIGKILL after 1s) -- **Tool calls are cancelled** — only the currently-executing one runs, the rest are skipped -- **Multiple messages are combined** — messages sent during interruption are joined into one prompt -- **`/stop` command** — interrupts without queuing a follow-up message +- **Model generation restarts with context** — reasoning already shown and visible partial text are retained as an ordinary assistant checkpoint +- **Completed work stays available** — prior tool calls and results remain in the turn +- **Running tools finish safely** — the correction is applied at the next tool-result boundary instead of killing the tool +- **`/stop` remains a hard stop** — use it to cancel the active turn and foreground work ### Queue vs interrupt vs steer (busy-input mode) -By default, messaging a busy agent interrupts it. Two other modes are available: +By default, messaging a busy agent redirects its active turn. Two other modes are available: - `queue` — follow-up messages wait and run as the next turn after the current task finishes. - `steer` — follow-up messages are injected into the current run via `/steer`, arriving at the agent after the next tool call. No interrupt, no new turn. Falls back to `queue` behavior if the agent hasn't started yet. @@ -376,7 +376,7 @@ display: The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob (`"💡 First-time tip — …"`). The reminder fires once per install — a flag under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. -If you find the busy-ack noisy — especially with voice input or rapid-fire messages — set `display.busy_ack_enabled: false`. Your input is still queued/steered/interrupts as normal, only the chat reply is silenced. +If you find the busy acknowledgment noisy, set `display.busy_ack_enabled: false`. Input handling is unchanged; only the confirmation message is hidden. ## Tool Progress Notifications From 3d40a1cbf2421419ee011095678c423cbe22a0bf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:51:50 -0400 Subject: [PATCH 5/6] feat(desktop): Cursor-style stop-and-correct on the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Enter (and the primary send button) while a turn is running now redirects the live turn with the typed correction instead of queueing it — matching Cursor's stop-and-correct. Removes the now-redundant steering-wheel button (redirect is the default gesture) and teaches the primary button the `steer` action. Attachments still queue; slash commands still run inline. --- .../src/app/chat/composer/controls.tsx | 46 ++++--------------- .../composer/hooks/use-composer-submit.ts | 19 +++++--- apps/desktop/src/app/chat/composer/index.tsx | 8 ++-- 3 files changed, 25 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7852ee81ef56..314bbd4c89cc 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,11 +1,9 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' -import { formatCombo } from '@/lib/keybinds/combo' +import { AudioLines, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' @@ -42,7 +40,6 @@ export function ComposerControls({ autoSpeak, busy, busyAction, - canSteer, canSubmit, compactModelPill = false, conversation, @@ -51,13 +48,11 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer, onToggleAutoSpeak }: { autoSpeak: boolean busy: boolean - busyAction: 'queue' | 'stop' - canSteer: boolean + busyAction: 'steer' | 'queue' | 'stop' canSubmit: boolean compactModelPill?: boolean conversation: ConversationProps @@ -66,49 +61,24 @@ export function ComposerControls({ state: ChatBarState voiceStatus: VoiceStatus onDictate: () => void - onSteer: () => void onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer - const steerCombo = formatCombo('mod+enter') - const steerLabel = `${c.steer} (${steerCombo})` - - const steerTip = ( - - {c.steer} - - - ) if (conversation.active) { return } const showVoicePrimary = !busy && !hasComposerPayload + // steer + stop both interrupt the live turn (the difference is whether a + // correction rides along), so they share the stop glyph; only queue differs. + const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop return (
- {/* While the agent runs and the user is typing, steer takes over the mic's - slot rather than crowding the row with an extra button. */} - {canSteer ? ( - - - - ) : ( - - )} + {showVoicePrimary ? ( @@ -127,9 +97,9 @@ export function ComposerControls({ ) : ( - +