"""Regression: apperror must close SSE relay drain loops. Backend emits `apperror` as a terminal event with no trailing `stream_end` (streaming.py returns immediately after put('apperror', ...)). Relay loops that only treat ("stream_end", "error", "cancel") as close signals hang forever — the phantom name `error` is never emitted. The authoritative close set is api.run_journal.SSE_RELAY_CLOSE_EVENTS. """ from __future__ import annotations from pathlib import Path from api.run_journal import SSE_RELAY_CLOSE_EVENTS, TERMINAL_SSE_EVENTS def test_relay_close_events_include_apperror_not_only_phantom_error(): assert "apperror" in SSE_RELAY_CLOSE_EVENTS assert "stream_end" in SSE_RELAY_CLOSE_EVENTS assert "cancel" in SSE_RELAY_CLOSE_EVENTS # `error` may remain for legacy/adapter frames, but must not be the sole # stand-in for apperror. assert "apperror" in SSE_RELAY_CLOSE_EVENTS # `done` must NOT close the live chat relay — title + stream_end follow it. assert "done" not in SSE_RELAY_CLOSE_EVENTS assert "done" in TERMINAL_SSE_EVENTS def test_routes_relay_loops_import_sse_relay_close_events(): src = Path("api/routes.py").read_text(encoding="utf-8") assert "SSE_RELAY_CLOSE_EVENTS" in src # The three historic hardcoded tuples must be gone. assert 'event in ("stream_end", "error", "cancel")' not in src assert 'event in ("stream_end", "error", "cancel")' not in src assert '_is_terminal = event in ("stream_end", "error", "cancel")' not in src def test_cancel_drop_list_allows_apperror_not_phantom_error(): src = Path("api/streaming.py").read_text(encoding="utf-8") assert "event not in ('cancel', 'apperror')" in src assert "event not in ('cancel', 'error')" not in src def test_apperror_terminates_relay_close_predicate(): """Observable behavior of the shared predicate used by relay loops.""" assert "apperror" in SSE_RELAY_CLOSE_EVENTS # Simulate the drain decision: apperror must stop the loop. events = ["token", "tool", "apperror", "token"] closed_at = None for i, event in enumerate(events): if event in SSE_RELAY_CLOSE_EVENTS: closed_at = i break assert closed_at == 2 # done alone must not stop the relay (title / stream_end still pending). assert "done" not in SSE_RELAY_CLOSE_EVENTS def test_chat_sse_stream_unsubscribes_on_apperror(monkeypatch): """Live /api/chat/stream must exit the drain loop when apperror arrives.""" import io import queue from urllib.parse import urlparse import api.routes as routes class _Handler: def __init__(self): self.status = None self.headers = {} self.wfile = io.BytesIO() self.command = "GET" self.path = "/" self.client_address = ("127.0.0.1", 12345) def send_response(self, status): self.status = status def send_header(self, key, value): self.headers[key] = value def end_headers(self): pass class _FakeStream: def __init__(self): self.q = queue.Queue() self.q.put_nowait(("token", {"text": "hi"}, "run1:1")) self.q.put_nowait(("apperror", {"message": "provider failed"}, "run1:2")) self.unsubscribed = False def subscribe_with_snapshot(self): return self.q, {"last_event_id": None, "offline_buffered_events": 0} def unsubscribe(self, q): self.unsubscribed = q is self.q stream = _FakeStream() monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_a, **_k: True) monkeypatch.setattr(routes, "STREAMS", {"run1": stream}) import api.config as config monkeypatch.setattr( config, "STREAMS", {"run1": stream}, ) monkeypatch.setattr( routes, "_sse_replay_run_journal_gap_checked", lambda *_a, **_k: (False, None), ) def _sleep(_seconds): raise BrokenPipeError("hung waiting after apperror") monkeypatch.setattr(routes.time, "sleep", _sleep) handler = _Handler() routes._handle_sse_stream(handler, urlparse("/api/chat/stream?stream_id=run1")) assert stream.unsubscribed is True, "handler must unsubscribe after apperror" body = handler.wfile.getvalue().decode("utf-8", errors="replace") assert "event: apperror" in body