mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
Update the gateway's live turn projection after an accepted correction so a warm session resume does not add the stale original prompt beside the persisted correction. Cover the inference-time correction path end to end and assert both redirect entry points refresh the live user text.
260 lines
10 KiB
Python
260 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"]
|
|
assert session["inflight_turn"]["user"] == "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
|