hermes-agent/tests/test_tui_gateway_queue_on_busy.py
Brooklyn Nicholson 2dd4cbbe61 fix(tui_gateway): keep the original prompt when a redirect corrects a turn
An accepted mid-turn redirect wrote its correction over inflight_turn["user"].
That field is the only user text session.resume can replay, so the prompt that
started the turn was gone the moment the user typed again while it ran. On the
next resume the client rebuilt the thread without it.

Record corrections in their own list instead, alongside the prompt. Renamed
_replace_inflight_user to _record_inflight_correction now that it appends.
_start_inflight_turn rebuilds the dict wholesale, so corrections cannot leak
into a later turn.
2026-07-26 19:43:23 -05:00

262 lines
10 KiB
Python

"""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: redirect the live turn by default, with the legacy interrupt + queue
path retained as a compatibility fallback.
"""
import threading
import time
import types
from tui_gateway import server
def _session(agent=None, **extra):
return {
"agent": agent if agent is not None else types.SimpleNamespace(),
"session_key": "session-key",
"history": [],
"history_lock": threading.Lock(),
"history_version": 0,
"running": False,
"transport": None,
"attached_images": [],
**extra,
}
# ── _enqueue_prompt ────────────────────────────────────────────────────────
def test_enqueue_pins_text_and_transport():
session = _session()
server._enqueue_prompt(session, "hello", "ws-1")
assert session["queued_prompt"] == {"text": "hello", "transport": "ws-1"}
def test_enqueue_merges_second_arrival_losslessly():
session = _session()
server._enqueue_prompt(session, "first", "ws-1")
server._enqueue_prompt(session, "second", "ws-2")
assert session["queued_prompt"]["text"] == "first\n\nsecond"
# Latest transport wins so the drain streams to the most recent client.
assert session["queued_prompt"]["transport"] == "ws-2"
# ── _handle_busy_submit (policy) ───────────────────────────────────────────
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)
session["inflight_turn"] = {"user": "original request", "assistant": "partial reply"}
resp = server._handle_busy_submit("r1", "sid", session, "redirect", "ws-1")
assert resp["result"]["status"] == "redirected"
assert seen == ["redirect"]
# Appended, not overwritten: the original prompt must stay recoverable.
assert session["inflight_turn"]["user"] == "original request"
assert session["inflight_turn"]["corrections"] == ["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))
session = _session(agent=agent, running=True)
resp = server._handle_busy_submit("r1", "sid", session, "redirect", "ws-1")
assert resp["result"]["status"] == "queued"
deadline = time.monotonic() + 1
while calls["interrupt"] != 1 and time.monotonic() < deadline:
time.sleep(0.01)
assert calls["interrupt"] == 1
assert session["queued_prompt"]["text"] == "redirect"
def test_busy_queue_mode_queues_without_interrupting(monkeypatch):
monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "queue")
calls = {"interrupt": 0}
agent = types.SimpleNamespace(interrupt=lambda *a, **k: calls.__setitem__("interrupt", calls["interrupt"] + 1))
session = _session(agent=agent, running=True)
resp = server._handle_busy_submit("r1", "sid", session, "later", "ws-1")
assert resp["result"]["status"] == "queued"
assert calls["interrupt"] == 0
assert session["queued_prompt"]["text"] == "later"
def test_busy_steer_mode_injects_when_accepted(monkeypatch):
monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "steer")
agent = types.SimpleNamespace(steer=lambda text: True, interrupt=lambda *a, **k: None)
session = _session(agent=agent, running=True)
resp = server._handle_busy_submit("r1", "sid", session, "nudge", "ws-1")
assert resp["result"]["status"] == "steered"
assert session.get("queued_prompt") is None
def test_busy_steer_mode_falls_back_to_queue_when_rejected(monkeypatch):
monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "steer")
agent = types.SimpleNamespace(steer=lambda text: False, interrupt=lambda *a, **k: None)
session = _session(agent=agent, running=True)
resp = server._handle_busy_submit("r1", "sid", session, "nudge", "ws-1")
assert resp["result"]["status"] == "queued"
assert session["queued_prompt"]["text"] == "nudge"
def test_busy_interrupt_does_not_hold_history_lock_or_delay_queue(monkeypatch):
monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt")
interrupt_started = threading.Event()
release_interrupt = threading.Event()
def blocking_interrupt():
interrupt_started.set()
release_interrupt.wait(timeout=2)
session = _session(
agent=types.SimpleNamespace(interrupt=blocking_interrupt),
running=True,
)
started = time.monotonic()
resp = server._handle_busy_submit("r1", "sid", session, "keep this", "ws-1")
assert resp["result"]["status"] == "queued"
assert time.monotonic() - started < 0.25
assert session["queued_prompt"]["text"] == "keep this"
assert interrupt_started.wait(timeout=1)
assert session["history_lock"].acquire(timeout=0.25)
session["history_lock"].release()
release_interrupt.set()
def test_busy_helper_retries_when_turn_finished(monkeypatch):
monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt")
session = _session(running=False)
assert server._handle_busy_submit("r1", "sid", session, "run now", "ws-1") is None
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):
fired = {}
monkeypatch.setattr(
server, "_run_prompt_submit",
lambda rid, sid, session, text: fired.update(rid=rid, sid=sid, text=text),
)
session = _session(queued_prompt={"text": "go", "transport": "ws-9"})
assert server._drain_queued_prompt("r1", "sid", session) is True
assert fired == {"rid": "r1", "sid": "sid", "text": "go"}
assert session["running"] is True
assert session["queued_prompt"] is None
assert session["transport"] == "ws-9"
def test_drain_noop_when_nothing_queued(monkeypatch):
monkeypatch.setattr(server, "_run_prompt_submit", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not fire")))
session = _session()
assert server._drain_queued_prompt("r1", "sid", session) is False
assert session["running"] is False
def test_drain_noop_when_session_already_running(monkeypatch):
"""A fresh turn that claimed the session beats a stale queued entry —
the drain leaves it for that turn's own tail."""
monkeypatch.setattr(server, "_run_prompt_submit", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not fire")))
session = _session(running=True, queued_prompt={"text": "go", "transport": None})
assert server._drain_queued_prompt("r1", "sid", session) is False
assert session["queued_prompt"]["text"] == "go"
def test_drain_releases_running_on_dispatch_failure(monkeypatch):
def _boom(*a, **k):
raise RuntimeError("dispatch failed")
monkeypatch.setattr(server, "_run_prompt_submit", _boom)
session = _session(queued_prompt={"text": "go", "transport": None})
assert server._drain_queued_prompt("r1", "sid", session) is True
# Failure must not leave the session wedged as running.
assert session["running"] is False