"""Regression tests for #1694 terminal stream cleanup ownership. Terminal SSE events for one session must not mutate another currently viewed active pane. The owning session's persisted/runtime stream marker can be cleared, but global pane state such as ``clearInflight()``, approval/clarify polling, and ``setBusy(false)`` must be gated to the session that owns the active pane/card. """ from pathlib import Path REPO_ROOT = Path(__file__).parent.parent MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") def _body_from_brace(src: str, brace: int, label: str) -> str: assert brace >= 0, f"body opening brace not found for: {label}" depth = 1 i = brace + 1 while i < len(src) and depth: ch = src[i] if ch == "{": depth += 1 elif ch == "}": depth -= 1 i += 1 assert depth == 0, f"body did not close for: {label}" return src[brace + 1 : i - 1] def _brace_body_after(src: str, marker: str) -> str: start = src.find(marker) assert start >= 0, f"marker not found: {marker}" brace = src.find("{", start) return _body_from_brace(src, brace, marker) def _event_body(event_name: str) -> str: return _brace_body_after(MESSAGES_JS, f"source.addEventListener('{event_name}'") def _function_body(name: str) -> str: marker = f"function {name}(" start = MESSAGES_JS.find(marker) assert start >= 0, f"function not found: {name}" signature_end = MESSAGES_JS.find("){", start) assert signature_end >= 0, f"function body not found: {name}" return _body_from_brace(MESSAGES_JS, signature_end + 1, name) def test_terminal_handlers_use_session_owned_cleanup_helpers(): """Patch #1694 should centralize terminal cleanup behind owner-aware helpers.""" attach_body = _function_body("attachLiveStream") assert "function _clearOwnerInflightState()" in attach_body owner_helper = _function_body("_clearOwnerInflightState") assert "delete INFLIGHT[activeSid]" in owner_helper assert "clearInflightState(activeSid)" in owner_helper assert "_clearActivePaneInflightIfOwner();" in owner_helper assert "function _clearActivePaneInflightIfOwner()" in attach_body assert "function _clearApprovalForOwner()" in attach_body assert "function _clearClarifyForOwner(" in attach_body assert "function _setActivePaneIdleIfOwner(" in attach_body def test_done_event_does_not_clear_active_pane_for_background_session(): """A background done event may clear its owner marker, not the active pane.""" body = _event_body("done") assert "_clearOwnerInflightState();" in body assert "clearInflight();clearInflightState(activeSid)" not in body assert "delete INFLIGHT[activeSid];\n clearInflight();" not in body assert "renderSessionList();setBusy(false)" not in body assert "_setActivePaneIdleIfOwner" in body def test_error_and_cancel_events_do_not_blanket_stop_active_pane_polling(): """Background app errors/cancels must not stop another pane's prompt polling.""" for event_name in ("apperror", "cancel"): body = _event_body(event_name) assert "_clearOwnerInflightState();" in body, event_name assert "_clearApprovalForOwner" in body, event_name assert "_clearClarifyForOwner" in body, event_name assert "stopApprovalPolling();stopClarifyPolling();" not in body, event_name assert "clearInflight();clearInflightState(activeSid)" not in body, event_name def test_reconnect_settled_and_error_paths_keep_cleanup_session_scoped(): """Reconnect terminal cleanup paths should follow the same owner model.""" restore_body = _function_body("_restoreSettledSession") error_body = _function_body("_handleStreamError") combined = restore_body + "\n" + error_body assert combined.count("_clearOwnerInflightState();") >= 2 assert "delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid)" not in combined assert "stopApprovalPolling();stopClarifyPolling();" not in combined assert "renderSessionList();setBusy(false)" not in combined assert "_setActivePaneIdleIfOwner" in combined def test_stream_end_without_done_restores_settled_session_before_closing(): """If a journal/replay emits stream_end without done, the UI must settle from /api/session. A close-only stream_end handler leaves live Thinking/inflight DOM around and never replaces the pane with the persisted transcript when done is missing. """ body = _event_body("stream_end") restore_idx = body.find("_restoreSettledSession()") close_idx = body.rfind("source.close()") finalized_idx = body.find("_streamFinalized=true") assert restore_idx != -1, "stream_end handler must restore settled session when done is absent" assert close_idx != -1, "stream_end handler must still close the EventSource" assert restore_idx < close_idx, "restore must be attempted before closing the stream" assert finalized_idx != -1, "stream_end terminal path must suppress trailing rAF/render work" def test_done_handler_is_idempotent_for_replay_or_duplicate_done_events(): """Duplicate/replayed done events must not replay completion sound or duplicate render.""" body = _event_body("done") first_stmt = body.strip().splitlines()[0].strip() assert "_streamFinalized" in first_stmt and "return" in first_stmt, ( "done handler must return early when the stream was already finalized" ) guard_idx = body.find("if(_streamFinalized) return;") sound_idx = body.find("playNotificationSound();") assert sound_idx != -1, "done handler should still play completion sound once" assert guard_idx != -1 and guard_idx < sound_idx, ( "completion sound must be behind the duplicate-done finalization guard" )