Files
hermes-agent/tests/tui_gateway/test_none_agent_turn_guard.py
T
teknium1 6f0be923bc fix(tui_gateway): refuse a turn whose session has no agent instead of crashing the turn thread
`_admit_prompt_turn` is the gate every turn source crosses (prompt.submit,
auto-continue, heartbeat/loop ticks, bot mailbox, compute host). When the
record's agent is None (a deferred build that attached nothing, see previous
commit) it used to hand the None agent through; `_invoke_agent` raised
AttributeError, `_recover_turn_exception` emitted a frame, and then the
`finally` dereferenced `st.agent.interim_assistant_callback` again — the
turn thread died before `running = False`, so the prompt vanished and the
session stayed "busy" for every later prompt.

Now the gate releases `running`, emits the same retryable
`agent_init_failed` terminal frame `_run_after_agent_ready` uses (carrying
the recorded build reason when there is one), logs the refusal, and returns
None so `_run_prompt_submit` reports the turn as not started. The turn body
therefore never sees a None agent and needs no guard in its `finally`.

Live probe (real `_run_prompt_submit`, inline thread, agent None):
before: `CRASH out of _run_prompt_submit: AttributeError ... running: True`
after:  `returned: False ... error_surface {'layer': 'runtime', 'code':
'agent_init_failed', 'retryable': True} running: False`

Fixes #111531
Co-authored-by: zqy1-1 <185413950+zqy1-1@users.noreply.github.com>
2026-09-15 18:52:43 -07:00

99 lines
4.4 KiB
Python

"""A turn must never run against a session record whose agent is ``None``.
The deferred agent build can finish WITHOUT attaching an agent: when the record is replaced or
closed while the build runs, ``_build`` leaves early while its ``finally`` still sets
``agent_ready``. Such a prompt used to reach the turn body, which dereferenced
``session["agent"]`` twice (``_invoke_agent`` and the turn's ``finally``) — the turn thread died
with ``running`` still True, the prompt vanished and the session stayed "busy" (#111531).
"""
from __future__ import annotations
import threading
import types
from tui_gateway import server
from tui_gateway.user_messages import AGENT_BUILD_ABANDONED
class _InlineThread:
"""Run the turn synchronously so tests observe its final state."""
def __init__(self, target=None, daemon=None, args=(), kwargs=None):
self._target, self._args, self._kwargs = target, args, kwargs or {}
def start(self):
if self._target is not None:
self._target(*self._args, **self._kwargs)
def is_alive(self):
return False
def join(self, timeout=None):
return None
def _session(agent, **extra):
return {
"agent": agent, "agent_error": None, "session_key": "gw-session-key", "history": [],
"history_lock": threading.RLock(), "history_version": 0, "running": True, "attached_images": [],
"image_counter": 0, "cols": 80, "slash_worker": None, "show_reasoning": False,
"tool_progress_mode": "all", "inflight_turn": None, **extra}
def _turn_env(monkeypatch, tmp_path) -> list:
emitted: list[tuple] = []
monkeypatch.setattr(server.threading, "Thread", _InlineThread)
monkeypatch.setattr(server, "_emit", lambda event_type, sid, payload=None: emitted.append((event_type, sid, payload)))
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda sid, session: None)
monkeypatch.setattr(server, "_session_cwd", lambda session: str(tmp_path))
monkeypatch.setattr(server, "_register_session_cwd", lambda session: None)
monkeypatch.setattr(server, "_tts_stream_begin", lambda: None)
monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **k: None)
monkeypatch.setattr(server, "_get_usage", lambda agent: {})
return emitted
def test_turn_without_agent_is_refused_with_retryable_frame(monkeypatch, tmp_path):
"""The recorded build reason reaches the client as a retryable runtime frame; ``running`` is released."""
emitted = _turn_env(monkeypatch, tmp_path)
session = _session(None, agent_error=AGENT_BUILD_ABANDONED)
assert server._run_prompt_submit("rid", "ui-sid", session, "继续") is False
frames = [p for (t, _sid, p) in emitted if t == "message.complete"]
assert len(frames) == 1
assert frames[0]["status"] == "error" and frames[0]["recoverable"] is True
assert frames[0]["error"] == AGENT_BUILD_ABANDONED
assert frames[0]["error_surface"] == {"layer": "runtime", "code": "agent_init_failed", "retryable": True}
assert session["running"] is False
assert session["inflight_turn"]["status"] == "error" # retained for session.resume
# Control: a built agent still runs the turn to completion and clears the interim closure.
agent = types.SimpleNamespace(
session_id="agent-sid-1", run_conversation=lambda *a, **k: {"final_response": "done"},
clear_interrupt=lambda: None)
emitted.clear()
server._run_prompt_submit("rid", "ui-sid", _session(agent), "go")
assert [p["status"] for (t, _sid, p) in emitted if t == "message.complete"] == ["complete"]
assert agent.interim_assistant_callback is None
def test_replaced_record_build_records_reason_and_leaves_agent_unset(monkeypatch, tmp_path):
"""A build whose record was swapped mid-flight sets ``agent_ready`` AND records why nothing attached."""
monkeypatch.setattr(server.threading, "Thread", _InlineThread)
monkeypatch.setattr(server, "_await_resume_history", lambda sid, current: False)
sid = "replaced-record"
session = _session(None, agent_ready=threading.Event(), cwd=str(tmp_path), profile_home=None)
server._sessions[sid] = session
try:
server._start_agent_build(sid, session)
finally:
server._sessions.pop(sid, None)
assert session["agent"] is None
assert session["agent_ready"].is_set()
assert session["agent_error"] == AGENT_BUILD_ABANDONED