"""Steer must use the active worker, not a compression-sensitive cache key.""" import queue from types import SimpleNamespace from unittest.mock import MagicMock import pytest from api import config, streaming from tests.test_real_steer import _make_handler, _captured_response @pytest.fixture def scene(monkeypatch): agent = MagicMock() agent.session_id = "compressed-child" agent.steer.return_value = True other = MagicMock() other.session_id = "other-session" monkeypatch.setattr(config, "SESSION_AGENT_CACHE", {"original": (agent, "sig")}) monkeypatch.setattr(config, "STREAMS", {"run": queue.Queue(), "other-run": queue.Queue()}) monkeypatch.setattr(config, "AGENT_INSTANCES", {"run": agent, "other-run": other}) monkeypatch.setattr(config, "STREAM_SESSION_OWNERS", {"run": "original", "other-run": "other-session"}) monkeypatch.setattr(config, "ACTIVE_RUNS", { "run": {"session_id": "original", "phase": "running", "backend": "legacy"}, "other-run": {"session_id": "other-session", "phase": "running", "backend": "legacy"}, }) sess = SimpleNamespace(active_stream_id="run") monkeypatch.setattr(streaming, "get_session", lambda sid: sess) return agent, other, sess def steer(): h = _make_handler() streaming._handle_chat_steer(h, {"session_id": "original", "text": "updated guidance"}) return _captured_response(h) @pytest.mark.parametrize("cache", ["rotated", "missing", "different"]) def test_active_worker_accepts_after_compression_without_cache_mutation(scene, cache): agent, other, _ = scene if cache == "missing": config.SESSION_AGENT_CACHE.clear() elif cache == "different": config.SESSION_AGENT_CACHE["original"] = (other, "sig") before = dict(config.SESSION_AGENT_CACHE) assert steer() == {"accepted": True, "fallback": None, "stream_id": "run"} agent.steer.assert_called_once_with("updated guidance") other.steer.assert_not_called() agent._session_db.close.assert_not_called() other._session_db.close.assert_not_called() agent.interrupt.assert_not_called() assert config.SESSION_AGENT_CACHE == before @pytest.mark.parametrize("bad", ["owner", "run-owner", "missing-owner", "missing-run", "cancelling", "dead", "gateway"]) def test_ambiguous_or_inactive_worker_is_never_steered_or_closed(scene, bad): agent, other, _ = scene # Matching cache must not bypass conflicting active-run ownership. agent.session_id = "original" if bad == "owner": config.STREAM_SESSION_OWNERS["run"] = "other-session" elif bad == "run-owner": config.ACTIVE_RUNS["run"]["session_id"] = "other-session" elif bad == "missing-owner": config.STREAM_SESSION_OWNERS.pop("run") elif bad == "missing-run": config.ACTIVE_RUNS.pop("run") elif bad == "cancelling": config.ACTIVE_RUNS["run"]["phase"] = "cancelling" elif bad == "dead": config.STREAMS.pop("run") elif bad == "gateway": config.ACTIVE_RUNS["run"]["backend"] = "gateway" assert steer()["accepted"] is False agent.steer.assert_not_called() other.steer.assert_not_called() agent._session_db.close.assert_not_called() @pytest.mark.parametrize("registered", [False, True]) @pytest.mark.parametrize("invalid", [None, "owner", "run-owner", "missing-owner", "missing-run-owner", "dead", "cancelling"]) def test_gateway_ownership_is_terminal_before_matching_local_cache(scene, registered, invalid): agent, other, _ = scene agent.session_id = "original" config.ACTIVE_RUNS["run"]["backend"] = "gateway" if not registered: config.AGENT_INSTANCES.pop("run") if invalid == "owner": config.STREAM_SESSION_OWNERS["run"] = "other-session" elif invalid == "run-owner": config.ACTIVE_RUNS["run"]["session_id"] = "other-session" elif invalid == "missing-owner": config.STREAM_SESSION_OWNERS.pop("run") elif invalid == "missing-run-owner": config.ACTIVE_RUNS["run"].pop("session_id") elif invalid == "dead": config.STREAMS.pop("run") elif invalid == "cancelling": config.ACTIVE_RUNS["run"]["phase"] = "cancelling" before = dict(config.SESSION_AGENT_CACHE) expected = ({"accepted": False, "fallback": "gateway_steer_queued", "stream_id": "run"} if invalid is None else {"accepted": False, "fallback": "stream_dead", "stream_id": None}) assert steer() == expected agent.steer.assert_not_called() other.steer.assert_not_called() agent.interrupt.assert_not_called() agent._session_db.close.assert_not_called() assert config.SESSION_AGENT_CACHE == before def test_cache_only_mismatch_does_not_close_an_agent_owned_by_another_run(scene): agent, other, _ = scene config.AGENT_INSTANCES.pop("run") config.SESSION_AGENT_CACHE["original"] = (other, "sig") assert steer()["accepted"] is False other._session_db.close.assert_not_called() other.steer.assert_not_called() @pytest.mark.parametrize("ownership", [ "both", "missing-owner", "missing-run-session", "empty-run-session", "missing-both", "missing-run-entry", "local-backend", "missing-backend", "empty-backend", "foreign-backend", "gateway-backend", ]) def test_cache_only_steer_requires_positive_stream_and_run_ownership(scene, ownership): """Cache-only Steer must prove ownership, not merely fail to refute it. With no registered worker and a matching cached agent on a live stream, a missing stream owner or a missing active-run session is ambiguous and must fail closed with ``stream_dead`` without calling ``agent.steer()``. Only both identities present and equal to the requesting session may enqueue. The active-run backend is revalidated the same way: only the explicit local backend tag registered by ``_run_agent_streaming`` may enqueue on the cached local agent. Gateway keeps its own terminal outcome; a missing, empty, or foreign backend is ambiguous and fails closed. """ agent, other, _ = scene agent.session_id = "original" config.AGENT_INSTANCES.pop("run") # Cache-only compatibility path. if ownership in ("missing-owner", "missing-both"): config.STREAM_SESSION_OWNERS.pop("run") if ownership in ("missing-run-session", "missing-both"): config.ACTIVE_RUNS["run"].pop("session_id") elif ownership == "empty-run-session": config.ACTIVE_RUNS["run"]["session_id"] = "" elif ownership == "missing-run-entry": config.ACTIVE_RUNS.pop("run") elif ownership == "local-backend": config.ACTIVE_RUNS["run"]["backend"] = streaming.WEBUI_LOCAL_CHAT_BACKEND elif ownership == "missing-backend": config.ACTIVE_RUNS["run"].pop("backend") elif ownership == "empty-backend": config.ACTIVE_RUNS["run"]["backend"] = "" elif ownership == "foreign-backend": config.ACTIVE_RUNS["run"]["backend"] = "foreign" elif ownership == "gateway-backend": config.ACTIVE_RUNS["run"]["backend"] = "gateway" before = dict(config.SESSION_AGENT_CACHE) assert "run" in config.STREAMS result = steer() if ownership in ("both", "local-backend"): assert result == {"accepted": True, "fallback": None, "stream_id": "run"} agent.steer.assert_called_once_with("updated guidance") elif ownership == "gateway-backend": assert result == {"accepted": False, "fallback": "gateway_steer_queued", "stream_id": "run"} agent.steer.assert_not_called() else: assert result == {"accepted": False, "fallback": "stream_dead", "stream_id": None} agent.steer.assert_not_called() other.steer.assert_not_called() agent.interrupt.assert_not_called() agent._session_db.close.assert_not_called() assert config.SESSION_AGENT_CACHE == before assert "run" in config.STREAMS def test_local_worker_registers_the_backend_cache_only_steer_requires(monkeypatch): """The in-process worker must tag its active run with the local backend. Cache-only Steer only enqueues on that explicit tag, so a worker that registered without it would make every cache-only Steer fail closed. """ captured = {} class _Registered(Exception): pass def register(stream_id, **metadata): captured[stream_id] = metadata raise _Registered() for name in ("STREAMS", "CANCEL_FLAGS", "STREAM_PARTIAL_TEXT", "STREAM_REASONING_TEXT", "STREAM_LIVE_TOOL_CALLS"): isolated = {"run": queue.Queue()} if name == "STREAMS" else {} monkeypatch.setattr(config, name, isolated) monkeypatch.setattr(streaming, name, isolated) monkeypatch.setattr(streaming, "register_active_run", register) with pytest.raises(_Registered): streaming._run_agent_streaming("original", "hi", "m", "/tmp", "run") assert captured["run"]["session_id"] == "original" assert captured["run"]["backend"] == streaming.WEBUI_LOCAL_CHAT_BACKEND assert streaming.WEBUI_LOCAL_CHAT_BACKEND assert streaming.WEBUI_LOCAL_CHAT_BACKEND != "gateway" def test_http_response_is_written_after_stream_lock_release(scene, monkeypatch): from api import helpers def respond(handler, payload): assert config.STREAMS_LOCK.acquire(blocking=False) config.STREAMS_LOCK.release() return payload monkeypatch.setattr(helpers, "j", respond) result = streaming._handle_chat_steer(None, {"session_id": "original", "text": "hint"}) assert result["accepted"] is True @pytest.mark.parametrize("behavior", ["reject", "raise", "unsupported"]) def test_live_worker_reports_real_acceptance(scene, behavior): agent, _, _ = scene if behavior == "reject": agent.steer.return_value = False elif behavior == "raise": agent.steer.side_effect = RuntimeError("fixture") else: agent.steer = None assert steer()["accepted"] is False agent.interrupt.assert_not_called() @pytest.mark.parametrize("registered", [True, False]) @pytest.mark.parametrize("phase", ["starting", "running", "finalizing", "cancelling", "done", "unknown", "", None]) def test_local_steer_requires_an_explicit_consuming_phase(scene, registered, phase): agent, _, _ = scene agent.session_id = "original" if not registered: config.AGENT_INSTANCES.pop("run") if phase is None: config.ACTIVE_RUNS["run"].pop("phase") else: config.ACTIVE_RUNS["run"]["phase"] = phase result = steer() assert result["accepted"] is (phase in {"starting", "running"}) assert agent.steer.call_count == (1 if result["accepted"] else 0) if not result["accepted"]: assert result["fallback"] == ("not_running" if phase == "finalizing" else "stream_dead")