"""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(source,{status:true})") if restore_idx == -1: restore_idx = body.find("_restoreSettledSession(source)") close_idx = body.find("_closeSource(source)", restore_idx) if close_idx == -1: close_idx = body.find("_finalizeStreamEndFallback(source)", restore_idx) finalized_idx = body.find("_streamFinalized=true", restore_idx) if finalized_idx == -1: finalized_idx = body.find("_finalizeStreamEndFallback(source)", restore_idx) 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 owning 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_settled_restore_and_error_close_only_the_event_source_owner(): """Late stale-source async cleanup must not close a newer reconnect source.""" restore_body = _function_body("_restoreSettledSession") error_body = _function_body("_handleStreamError") event_body = _event_body("error") assert "async function _restoreSettledSession(source, options=null)" in MESSAGES_JS assert "function _handleStreamError(source)" in MESSAGES_JS assert "_closeSource(source);" in restore_body assert "_closeSource(source);" in error_body assert "_restoreSettledSession(source, {preserveVisibleOnShorterTerminalSnapshot:true})" in event_body assert "_handleStreamError(source)" in event_body assert "_restoreSettledSession())" not in event_body assert "_handleStreamError();" not in event_body 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" ) def test_attention_events_use_distinct_sound_from_completion(): approval_body = _event_body("approval") clarify_body = _event_body("clarify") done_body = _event_body("done") attention_body = _function_body("playAttentionSound") assert "playAttentionSound(_attentionSoundKey(activeSid,'approval',1));" in approval_body assert "playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));" in clarify_body for body in (approval_body, clarify_body): assert "playNotificationSound();" not in body assert "playNotificationSound();" in done_body assert "playAttentionSound();" not in done_body assert "osc.type='sine'" in attention_body assert "window._lastAttentionSoundAt" in attention_body assert "nowMs-window._lastAttentionSoundAt<900" in attention_body assert "window._attentionSoundSeenKeys" in attention_body assert "seen.has(dedupeKey)" in attention_body assert "seen.set(dedupeKey,nowMs)" in attention_body assert "osc.frequency.setValueAtTime(880,ctx.currentTime);" in attention_body assert "osc.frequency.setValueAtTime(660,ctx.currentTime+0.075);" in attention_body assert "gain.gain.setValueAtTime(0.24,ctx.currentTime);" in attention_body assert "osc.stop(ctx.currentTime+0.24);" in attention_body assert "Notification sound failed" not in attention_body def test_active_reconnect_uses_run_journal_replay_cursor(): """Active-stream reconnects must send replay cursor params too. Once StreamChannel's offline buffer drops old frames, a still-active stream can only backfill the dropped gap from the run journal. Reconnecting without replay/after_seq/after_event_id silently resumes at the retained tail and loses the older gap. """ body = _event_body("error") active_status_idx = body.find("if(st&&st.active)") replay_status_idx = body.find("if(st&&st.replay_available)") assert active_status_idx != -1, "active reconnect branch not found" assert replay_status_idx != -1, "replay reconnect branch not found" active_block = body[active_status_idx:replay_status_idx] assert "${_runJournalReplayParams()}" in active_block assert "after_seq" in _function_body("_runJournalReplayParams") assert "after_event_id" in _function_body("_runJournalReplayParams") def test_attach_live_stream_registers_one_source_per_session_stream(): """Reconnect/compaction paths must not stack same-stream EventSources. The stream channel broadcasts each token to every subscriber. If the browser opens four live EventSources for the same run, one assistant paragraph is appended four times even though the run journal contains it once. """ close_body = _function_body("closeLiveStream") attach_body = _function_body("attachLiveStream") wire_body = _function_body("_wireSSE") error_body = _event_body("error") assert "const LIVE_STREAMS={};" in MESSAGES_JS assert "LIVE_STREAMS[activeSid]={streamId,source};" in wire_body assert "existingLive.source.close();" in wire_body assert "if(source&&live.source!==source) return;" in close_body assert "existingLive&&existingLive.streamId===streamId" in attach_body assert "_closeSource(source);" in error_body