Files
hermes-agent/tests/tools/test_terminal_yield_to_background.py
Teknium 463292351f fix: mid-turn user message no longer waits behind a foreground terminal command
A message typed while the agent runs (CLI busy_input_mode=interrupt, gateway
priority redirect, ACP redirect) goes through AIAgent.redirect(). During tool
execution redirect() degrades to steer(), whose delivery rides the tool result
— so a long foreground command (a `sleep 285` CI poller, a build) parked the
user's message until it exited. The UI printed "Redirected current turn" while
nothing happened for minutes.

redirect() now also asks the tool workers to YIELD (tools/interrupt.request_yield).
The local terminal backend's wait loop honours it: the drain thread is stopped, the
still-running Popen is adopted by the process registry as a notify_on_complete
background session (ProcessRegistry.adopt_local — output so far seeds the buffer,
the registry reader continues from the pipe), and the tool returns immediately with
status "yielded_to_background" + session_id. The command is never killed; the
completion notification arrives as usual and process(poll/wait/log/kill) work on it.

Non-local backends and internal env.execute() consumers pass no yield_handler and
are unaffected; a stale yield bit is cleared with the interrupt bit per worker tid.
2026-09-05 13:51:06 -07:00

89 lines
3.5 KiB
Python

"""A user message sent while a foreground terminal command runs must not wait for the command.
``AIAgent.redirect()`` during tool execution degrades to ``steer()``, whose delivery rides
the tool result — so a long foreground command (a 5-minute ``sleep`` poller, a build) parked
the user's message until it exited. Now redirect() also asks the tool workers to YIELD: the
local terminal backend hands the still-running process to the process registry as a
notify-on-complete background session and returns immediately, without killing it.
"""
import json
import os
import threading
import time
import pytest
from agent.interrupt_control import InterruptControlMixin
from tools import interrupt as interrupt_mod
from tools.process_registry import process_registry
from tools.terminal_tool import terminal_tool
pytestmark = pytest.mark.linux_only
class _Agent(InterruptControlMixin):
_executing_tools = True
_interrupt_requested = False
_pending_steer = None
_pending_redirect = None
api_mode = "chat_completions"
def __init__(self):
self._pending_steer_lock = threading.Lock()
self._pending_redirect_lock = threading.Lock()
self._tool_worker_threads = set()
self._tool_worker_threads_lock = threading.Lock()
self._execution_thread_id = None
def test_redirect_mid_command_yields_it_to_background_without_killing_it(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
agent = _Agent()
res = {}
def worker():
with agent._tool_worker_threads_lock:
agent._tool_worker_threads.add(threading.current_thread().ident)
t0 = time.monotonic()
res["result"] = json.loads(terminal_tool("echo started; sleep 60; echo done", task_id="yield-test", timeout=90))
res["elapsed"] = time.monotonic() - t0
t = threading.Thread(target=worker, daemon=True)
t.start()
time.sleep(1.5)
assert agent.redirect("also, check the session ids") is True
assert agent._pending_steer == "also, check the session ids" # still delivered as a steer
t.join(timeout=15)
assert not t.is_alive(), "terminal tool still blocked on the command after redirect()"
r = res["result"]
try:
assert r["status"] == "yielded_to_background"
assert r["exit_code"] is None and "started" in r["output"]
assert r["notify_on_complete"] is True
# The process is alive and tracked: poll/wait/kill and the completion notification work.
assert os.path.exists(f"/proc/{r['pid']}")
assert process_registry.poll(r["session_id"])["status"] == "running"
assert not interrupt_mod.is_thread_yield_requested(t.ident)
finally:
killed = process_registry.kill_process(r["session_id"])
assert killed["status"] == "killed"
evt = process_registry.completion_queue.get(timeout=5)
assert evt["session_id"] == r["session_id"]
def test_yield_request_without_steer_leaves_foreground_wait_alone():
"""No yield handler (internal env.execute consumers) -> the request is ignored and the
command runs to completion; an unrelated stale yield bit must not leak into it either."""
from tools.environments.local import LocalEnvironment
env = LocalEnvironment()
try:
interrupt_mod.request_yield(threading.current_thread().ident)
result = env.execute("echo alpha; sleep 0.3; echo omega", timeout=10)
assert result["returncode"] == 0
assert "omega" in result["output"]
finally:
interrupt_mod.consume_yield(threading.current_thread().ident)
env.cleanup()