mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge pull request #63104 from NousResearch/bb/active-turn-steering
feat: redirect active turns when users correct the agent
This commit is contained in:
commit
bb24364f25
30 changed files with 1531 additions and 194 deletions
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
|
||||
</span>
|
||||
)
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
{/* 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 ? (
|
||||
<Tip label={steerTip}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
className={GHOST_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={onSteer}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<SteeringWheel className={iconSize.sm} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
|
|
@ -127,9 +97,9 @@ export function ComposerControls({
|
|||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<Tip label={busy ? busyLabel : c.send}>
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
|
||||
aria-label={busy ? busyLabel : c.send}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ interface UseComposerSubmitArgs {
|
|||
activeQueueSessionKeyRef: RefObject<string | null>
|
||||
attachments: ComposerAttachment[]
|
||||
busy: boolean
|
||||
canSteer: boolean
|
||||
clearDraft: () => void
|
||||
disabled: boolean
|
||||
draftRef: RefObject<string>
|
||||
|
|
@ -43,7 +42,7 @@ interface UseComposerSubmitArgs {
|
|||
* queue meet. `submitDraft` is the one decision tree (queue-edit save · slash-
|
||||
* now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared
|
||||
* send-with-restore primitive (re-loads + re-stashes the draft if the gateway
|
||||
* rejects, so nothing is ever lost); `steerDraft` nudges the live turn. Reads
|
||||
* rejects, so nothing is ever lost); `steerDraft` redirects the live turn. Reads
|
||||
* the draft + queue APIs; owns no state of its own beyond the stable
|
||||
* external-submit listener ref.
|
||||
*/
|
||||
|
|
@ -52,7 +51,6 @@ export function useComposerSubmit({
|
|||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
canSteer,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
|
|
@ -151,7 +149,14 @@ export function useComposerSubmit({
|
|||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
dispatchSubmit(text)
|
||||
} else if (!attachments.length && text.trim()) {
|
||||
// Cursor-style stop-and-correct: interrupt the live turn and redirect
|
||||
// it with this text. redirect() preserves the shown reasoning/work; if
|
||||
// the turn already ended, steerDraft re-queues so nothing is lost.
|
||||
steerDraft()
|
||||
} else if (payloadPresent) {
|
||||
// Attachments can't ride a redirect (no tool-result image carriage) —
|
||||
// queue the whole payload for the next turn.
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
|
|
@ -173,16 +178,18 @@ 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) {
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
// Guard on live editor state, not the render-lagged `canSteer`: a redirect
|
||||
// fired on a fast Enter must not be dropped because state hasn't synced.
|
||||
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
|
||||
|
|
|
|||
|
|
@ -227,12 +227,15 @@ export function ChatBar({
|
|||
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
|
||||
// While busy: text redirects the live turn (Cursor-style stop-and-correct),
|
||||
// attachments queue for the next turn, an empty composer stops.
|
||||
const busyAction: 'steer' | 'queue' | 'stop' = canSteer ? 'steer' : hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// The submit engine — the orchestration seam where draft + queue meet. Owns
|
||||
// the submit decision tree, the send-with-restore primitive, and steer.
|
||||
const { steerDraft, submitDraft } = useComposerSubmit({
|
||||
|
|
@ -240,7 +243,6 @@ export function ChatBar({
|
|||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
canSteer,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
|
|
@ -721,7 +723,6 @@ export function ChatBar({
|
|||
autoSpeak={autoSpeak}
|
||||
busy={busy}
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
compactModelPill={poppedOut || compactPill}
|
||||
conversation={{
|
||||
|
|
@ -737,7 +738,6 @@ export function ChatBar({
|
|||
disabled={disabled}
|
||||
hasComposerPayload={hasComposerPayload}
|
||||
onDictate={dictate}
|
||||
onSteer={steerDraft}
|
||||
onToggleAutoSpeak={handleToggleAutoSpeak}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ interface HarnessHandle {
|
|||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
|
||||
redirectPrompt: (text: string) => Promise<boolean>
|
||||
/** @deprecated Use `redirectPrompt`. */
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
|
||||
}
|
||||
|
|
@ -160,6 +162,8 @@ function Harness({
|
|||
act(async () => actions.cancelRun(...args)) as Promise<void>,
|
||||
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
|
||||
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
|
||||
redirectPrompt: (...args: Parameters<typeof actions.redirectPrompt>) =>
|
||||
act(async () => actions.redirectPrompt(...args)) as Promise<boolean>,
|
||||
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
|
||||
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
|
||||
submitText: (...args: Parameters<typeof actions.submitText>) =>
|
||||
|
|
@ -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<string, unknown>[] = []
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
<Harness
|
||||
onReady={h => (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', () => {
|
|||
<Harness onReady={h => (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,20 +763,93 @@ describe('usePromptActions steerPrompt', () => {
|
|||
<Harness onReady={h => (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(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.steerPrompt(' ')).toBe(false)
|
||||
expect(await handle!.redirectPrompt(' ')).toBe(false)
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a queued redirect during the agent-build window and records the correction', async () => {
|
||||
// running=True but the agent is still building: the gateway queues the
|
||||
// correction instead of rejecting, so the composer must NOT re-queue it.
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
const capturedStates: Record<string, unknown>[] = []
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => capturedStates.push(state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await handle!.redirectPrompt('build-window nudge')).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.redirect', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'build-window nudge'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
expect((capturedStates.at(-1)?.messages as unknown[]).at(-1)).toMatchObject({
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'build-window nudge' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes the stored session and retries once when session.redirect reports "session not found"', async () => {
|
||||
const STORED_SESSION_ID = 'stored-db-xyz789'
|
||||
const RECOVERED_SESSION_ID = 'rt-recovered-456'
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let redirectAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'session.redirect') {
|
||||
redirectAttempts += 1
|
||||
|
||||
if (redirectAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
|
||||
return { status: 'redirected' } as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
|
||||
expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID, text: 'reconnect nudge' })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'reconnect nudge' })
|
||||
expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions restoreToMessage', () => {
|
||||
|
|
|
|||
|
|
@ -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<boolean> => {
|
||||
const text = sanitizeComposerInput(rawText).trim()
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
|
@ -612,25 +612,54 @@ export function usePromptActions({
|
|||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionSteerResponse>('session.steer', { session_id: sessionId, text })
|
||||
// Accepted whether the live turn was redirected in place or queued for
|
||||
// the next turn (the build window, before the agent is wired) — either
|
||||
// way the correction reaches the model, so record it once as a real user
|
||||
// message after the interrupted checkpoint, matching the durable core
|
||||
// transcript rather than a system note that changes role after reload.
|
||||
const send = async (id: string): Promise<boolean> => {
|
||||
const result = await requestGateway<SessionRedirectResponse>('session.redirect', { session_id: id, text })
|
||||
|
||||
if (result?.status === 'queued') {
|
||||
if (result?.status === 'redirected' || result?.status === 'queued') {
|
||||
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}`)
|
||||
appendSessionTextMessage(id, 'user', text)
|
||||
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await send(sessionId)
|
||||
} catch (err) {
|
||||
// A stale runtime id after reconnect 404s ("session not found"): resume
|
||||
// the stored session and retry once, mirroring stopPrompt so a
|
||||
// correction right after a reconnect isn't lost to the race.
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
|
||||
return await send(recoveredId)
|
||||
}
|
||||
} catch {
|
||||
// fall through — caller queues so nothing is lost
|
||||
}
|
||||
}
|
||||
// Swallow — caller queues the text so nothing is lost.
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway]
|
||||
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
|
|
@ -806,7 +835,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ export interface SessionSteerResponse {
|
|||
text?: string
|
||||
}
|
||||
|
||||
export interface SessionRedirectResponse {
|
||||
status?: 'redirected' | 'queued' | 'rejected'
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface SessionTitleResponse {
|
||||
title?: string
|
||||
// True when the session row isn't persisted yet and the title was queued
|
||||
|
|
|
|||
44
cli.py
44
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:
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
179
run_agent.py
179
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ EXPECTED_FIELDS = {
|
|||
"restart_with_compressed_messages",
|
||||
"restart_with_length_continuation",
|
||||
"restart_with_rebuilt_messages",
|
||||
"restart_with_redirected_messages",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -6835,6 +6835,78 @@ 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_redirect_queues_during_agent_build_window(monkeypatch):
|
||||
# A fresh turn flips running=True and builds the agent asynchronously, so
|
||||
# session["agent"] is briefly None. A correction landing here must queue
|
||||
# (lossless, reaches the model next turn), not hard-reject as unsupported.
|
||||
session = _session(running=True)
|
||||
session["agent"] = None
|
||||
server._sessions["sid"] = session
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.redirect",
|
||||
"params": {"session_id": "sid", "text": "wait, use SQLite"},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert resp["result"] == {"status": "queued", "text": "wait, use SQLite"}
|
||||
assert session["queued_prompt"]["text"] == "wait, use SQLite"
|
||||
|
||||
|
||||
def test_session_redirect_rejects_when_idle_without_agent(monkeypatch):
|
||||
# No live turn and no agent: nothing to redirect, and we must not queue a
|
||||
# phantom turn — keep the explicit unsupported rejection.
|
||||
session = _session(running=False)
|
||||
session["agent"] = None
|
||||
server._sessions["sid"] = session
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.redirect",
|
||||
"params": {"session_id": "sid", "text": "hi"},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert resp["error"]["code"] == 4010
|
||||
assert session.get("queued_prompt") is None
|
||||
|
||||
|
||||
def test_session_info_includes_mcp_servers(monkeypatch):
|
||||
fake_status = [
|
||||
{"name": "github", "transport": "http", "tools": 12, "connected": True},
|
||||
|
|
|
|||
|
|
@ -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,43 @@ 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")
|
||||
# Turn-build window: a fresh turn flips running=True and kicks off an async
|
||||
# agent build, so session["agent"] is briefly None. That is not an
|
||||
# unsupported runtime — queue the correction server-side so it reaches the
|
||||
# model as the next turn, instead of a misleading 4010 the client silently
|
||||
# swallows into a lost follow-up.
|
||||
if agent is None and session.get("running"):
|
||||
_enqueue_prompt(session, text, current_transport() or _stdio_transport)
|
||||
session["last_active"] = time.time()
|
||||
return _ok(rid, {"status": "queued", "text": text})
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue