diff --git a/tests/tui_gateway/test_none_agent_turn_guard.py b/tests/tui_gateway/test_none_agent_turn_guard.py new file mode 100644 index 0000000000..8b8d03611c --- /dev/null +++ b/tests/tui_gateway/test_none_agent_turn_guard.py @@ -0,0 +1,98 @@ +"""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 diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 4251ee87b2..d3e2b25f65 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -136,8 +136,23 @@ def _admit_prompt_turn( if not isinstance(inflight, dict) or inflight.get("status") == "error": _start_inflight_turn(session, text) agent = session["agent"] - with contextlib.suppress(Exception): - agent.clear_interrupt() + if agent is None: + session["running"] = False + else: + with contextlib.suppress(Exception): + agent.clear_interrupt() + if agent is None: + # A deferred build can finish without attaching an agent (its record was replaced or closed + # mid-build: ``agent_ready`` set, ``agent`` None, see ``_start_agent_build``). Every turn source + # crosses this gate, so refuse here with a retryable frame: the turn body used to dereference the + # missing agent twice (in ``_invoke_agent`` and again in its ``finally``), which killed the turn + # thread with ``running`` still True — the prompt vanished and the session stayed "busy" (#111531). + reason = session.get("agent_error") or AGENT_MISSING_FOR_TURN + logger.info("Refusing turn for session %s: no agent attached (%s)", session.get("session_key") or sid, reason) + _emit_terminal_turn_error( + sid, session, reason, + error_surface={"layer": "runtime", "code": "agent_init_failed", "retryable": True}) + return None return images, agent