diff --git a/static/messages.js b/static/messages.js
index aec6e11c..8142a714 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -5940,7 +5940,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
window._compressionUi.sessionId===activeSid&&
d.session&&d.session.session_id
){
- window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
+ if(window._compressionUi.phase==='running'){
+ // Turn completed (done event) but the compression UI is still in
+ // 'running' phase - the 'compressed' SSE event was lost or delayed.
+ // Clear the stale running state instead of leaving it active,
+ // which would surface a phantom "Compressing context" barrier.
+ // This covers both A->B (session rotation) and A->A (no rotation)
+ // since in both cases a running phase at done-time means the
+ // compressed event never arrived.
+ if(typeof clearCompressionUi==='function') clearCompressionUi();
+ else window._compressionUi=null;
+ } else {
+ window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
+ }
}
// Find the last assistant message once for both reasoning persistence and timestamp
lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
diff --git a/tests/test_compression_phantom_barrier.py b/tests/test_compression_phantom_barrier.py
new file mode 100644
index 00000000..c1cf9038
--- /dev/null
+++ b/tests/test_compression_phantom_barrier.py
@@ -0,0 +1,298 @@
+"""Behavioral regression tests for the phantom compression barrier.
+
+The harness loads the shipping classic scripts in a real browser page, replaces
+only the SSE transport, and dispatches events through attachLiveStream's real
+listeners. All HTTP requests are fulfilled in-process; no WebUI server or
+network access is used.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import sync_playwright
+
+ROOT = Path(__file__).resolve().parents[1]
+STATIC = ROOT / "static"
+INDEX_HTML = (STATIC / "index.html").read_text(encoding="utf-8")
+UI_JS = (STATIC / "ui.js").read_text(encoding="utf-8")
+MESSAGES_JS = (STATIC / "messages.js").read_text(encoding="utf-8")
+SUPPORT_SCRIPTS = [
+ (STATIC / name).read_text(encoding="utf-8")
+ for name in ("i18n.js", "icons.js", "assistant_turn_anchors.js")
+]
+
+def _matching_brace(source: str, opening: int) -> int:
+ """Return the closing brace paired with ``opening``."""
+ depth = 0
+ for index in range(opening, len(source)):
+ if source[index] == "{":
+ depth += 1
+ elif source[index] == "}":
+ depth -= 1
+ if depth == 0:
+ return index
+ raise AssertionError("unclosed JavaScript block in mutation target")
+
+
+def _revert_running_cleanup(source: str) -> str:
+ """Revert the reviewed phase branch inside the production done listener."""
+ done_start = source.index("source.addEventListener('done',e=>{")
+ done_end = source.index("source.addEventListener('stream_end'", done_start)
+ done_handler = source[done_start:done_end]
+ phase_match = re.search(
+ r"if\s*\(\s*window\._compressionUi\.phase\s*===\s*['\"]running['\"]\s*\)\s*\{",
+ done_handler,
+ )
+ assert phase_match, "running cleanup branch missing from production done listener"
+
+ branch_start = done_start + phase_match.start()
+ if_open = source.index("{", done_start + phase_match.start(), done_start + phase_match.end())
+ if_close = _matching_brace(source, if_open)
+ else_match = re.match(r"\s*else\s*\{", source[if_close + 1 :])
+ assert else_match, "running cleanup branch no longer has its rebind control"
+ else_open = if_close + 1 + else_match.end() - 1
+ else_close = _matching_brace(source, else_open)
+
+ old_branch = source[branch_start : else_close + 1]
+ assert "clearCompressionUi()" in old_branch
+ assert "sessionId:d.session.session_id" in old_branch.replace(" ", "")
+ reverted = "window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};"
+ return source[:branch_start] + reverted + source[else_close + 1 :]
+
+
+# Revert only the fix under review to its pre-fix unconditional rebind. The
+# ownership/automatic guard remains intact, so controls must behave identically.
+MUTATED_MESSAGES_JS = _revert_running_cleanup(MESSAGES_JS)
+
+# The real page DOM is used, but its deferred script/resource tags are removed:
+# scripts are injected explicitly below and every request is fulfilled locally.
+HARNESS_HTML = re.sub(r"", "", INDEX_HTML, flags=re.I | re.S)
+HARNESS_HTML = re.sub(r"]*>", "", HARNESS_HTML, flags=re.I)
+
+_MOCK_EVENT_SOURCE = r"""
+class MockEventSource {
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSED = 2;
+ static instances = [];
+ constructor(url) {
+ this.url = String(url);
+ this.readyState = MockEventSource.CONNECTING;
+ this.listeners = new Map();
+ MockEventSource.instances.push(this);
+ }
+ addEventListener(name, listener) {
+ const listeners = this.listeners.get(name) || [];
+ listeners.push(listener);
+ this.listeners.set(name, listeners);
+ }
+ removeEventListener(name, listener) {
+ this.listeners.set(name, (this.listeners.get(name) || []).filter(fn => fn !== listener));
+ }
+ emit(name, payload) {
+ const event = {type: name, data: JSON.stringify(payload)};
+ for (const listener of this.listeners.get(name) || []) listener(event);
+ }
+ close() { this.readyState = MockEventSource.CLOSED; }
+}
+window.EventSource = MockEventSource;
+window.MockEventSource = MockEventSource;
+"""
+
+# These are unrelated terminal-event surfaces. Compression state, timer, lock,
+# placeholder, rendering, attachLiveStream, and both SSE listeners remain the
+# production implementations.
+_STABILIZE_UNRELATED_UI = r"""
+appendLiveCompressionCard = () => false;
+resetTurnWorkspaceMutations = () => {};
+_resetStreamScrollFollow = () => {};
+ensureLiveWorklogShell = () => {};
+_shouldFollowMessagesOnDomReplace = () => false;
+_isMessagePaneNearBottom = () => false;
+_markSessionViewed = () => {};
+setBusy = () => {};
+setComposerStatus = () => {};
+setStatus = () => {};
+syncTopbar = () => {};
+renderMessages = () => {};
+renderSessionList = () => {};
+loadDir = () => {};
+playNotificationSound = () => {};
+sendBrowserNotification = () => {};
+api = async () => ({});
+"""
+
+_SCENARIO = r"""
+async ({kind, doneSid}) => {
+ const activeSid = 'session-a';
+ const streamId = 'stream-under-test';
+ S.session = {session_id: activeSid, messages: []};
+ S.messages = [];
+ S.toolCalls = [];
+ S.activeStreamId = streamId;
+ S.busy = true;
+ window._compressionUi = null;
+ window._compressionLockSid = null;
+
+ const input = document.getElementById('msg');
+ const cards = document.getElementById('liveCompressionCards');
+ input.placeholder = 'Original placeholder';
+ cards.innerHTML = '';
+ cards.style.display = '';
+
+ attachLiveStream(activeSid, streamId, []);
+ const source = MockEventSource.instances.at(-1);
+ if (!source) throw new Error('attachLiveStream did not construct EventSource');
+ if (!source.listeners.has('compressing') || !source.listeners.has('done')) {
+ throw new Error('production compressing/done listeners were not registered');
+ }
+
+ if (kind === 'running') {
+ source.emit('compressing', {session_id: activeSid});
+ } else if (kind === 'non-running') {
+ setCompressionUi({sessionId: activeSid, phase: 'done', automatic: true});
+ } else if (kind === 'manual') {
+ setCompressionUi({sessionId: activeSid, phase: 'running', automatic: false});
+ } else if (kind === 'unowned') {
+ setCompressionUi({phase: 'running', automatic: true});
+ } else if (kind === 'stale-owner') {
+ setCompressionUi({sessionId: 'session-stale', phase: 'running', automatic: true});
+ } else {
+ throw new Error(`unknown scenario: ${kind}`);
+ }
+
+ const before = {
+ state: window._compressionUi ? {...window._compressionUi} : null,
+ timerRunning: _compressionElapsedTimer !== null,
+ lock: _compressionSessionLock(),
+ placeholder: input.placeholder,
+ };
+
+ // A real clearCompressionUi call must remove this stale rendered surface.
+ cards.innerHTML = 'stale';
+ cards.style.display = '';
+
+ source.emit('done', {
+ session: {
+ session_id: doneSid,
+ messages: [{role: 'assistant', content: 'complete'}],
+ tool_calls: [],
+ },
+ stream_id: streamId,
+ });
+
+ const after = {
+ state: window._compressionUi ? {...window._compressionUi} : null,
+ timerRunning: _compressionElapsedTimer !== null,
+ lock: _compressionSessionLock(),
+ placeholder: input.placeholder,
+ cardsHtml: cards.innerHTML,
+ cardsDisplay: cards.style.display,
+ currentSid: S.session && S.session.session_id,
+ };
+ return {before, after};
+}
+"""
+
+
+@pytest.fixture(scope="module")
+def browser():
+ with sync_playwright() as playwright:
+ instance = playwright.chromium.launch(
+ headless=True,
+ args=["--no-sandbox", "--disable-dev-shm-usage"],
+ )
+ yield instance
+ instance.close()
+
+
+def _run(browser, kind: str, done_sid: str, *, mutated: bool = False) -> dict:
+ page = browser.new_page()
+ page.route(
+ "**/*",
+ lambda route: route.fulfill(
+ status=200,
+ content_type="text/html" if route.request.url == "http://harness.test/" else "text/plain",
+ body=HARNESS_HTML if route.request.url == "http://harness.test/" else "",
+ ),
+ )
+ page.add_init_script(_MOCK_EVENT_SOURCE)
+ try:
+ page.goto("http://harness.test/", wait_until="domcontentloaded")
+ for script in SUPPORT_SCRIPTS:
+ page.add_script_tag(content=script)
+ page.add_script_tag(content=UI_JS)
+ page.add_script_tag(content=MUTATED_MESSAGES_JS if mutated else MESSAGES_JS)
+ page.evaluate(_STABILIZE_UNRELATED_UI)
+ return page.evaluate(_SCENARIO, {"kind": kind, "doneSid": done_sid})
+ finally:
+ page.close()
+
+
+def _assert_running_prerequisite(result: dict) -> None:
+ before = result["before"]
+ assert before["state"]["sessionId"] == "session-a"
+ assert before["state"]["automatic"] is True
+ assert before["state"]["phase"] == "running"
+ assert before["timerRunning"] is True
+ assert before["lock"] == "session-a"
+ assert before["placeholder"] == "Type a message — it will queue and send after compression"
+
+
+def _assert_canonical_clear(result: dict, done_sid: str) -> None:
+ after = result["after"]
+ assert after == {
+ "state": None,
+ "timerRunning": False,
+ "lock": None,
+ "placeholder": "Original placeholder",
+ "cardsHtml": "",
+ "cardsDisplay": "none",
+ "currentSid": done_sid,
+ }
+
+
+@pytest.mark.parametrize("done_sid", ["session-a", "session-b"], ids=["A-to-A", "A-to-B"])
+def test_owned_running_state_is_cleared_through_real_sse_path(browser, done_sid):
+ result = _run(browser, "running", done_sid)
+ _assert_running_prerequisite(result)
+ _assert_canonical_clear(result, done_sid)
+
+
+@pytest.mark.parametrize("done_sid", ["session-a", "session-b"], ids=["A-to-A", "A-to-B"])
+def test_non_running_state_is_rebound_through_real_done_listener(browser, done_sid):
+ result = _run(browser, "non-running", done_sid)
+ assert result["before"]["state"]["phase"] == "done"
+ assert result["after"]["state"]["phase"] == "done"
+ assert result["after"]["state"]["sessionId"] == done_sid
+
+
+@pytest.mark.parametrize("kind", ["manual", "unowned", "stale-owner"])
+def test_unowned_compression_controls_are_untouched(browser, kind):
+ result = _run(browser, kind, "session-b")
+ assert result["after"]["state"] == result["before"]["state"]
+
+
+def test_reverting_cleanup_branch_breaks_owned_running_only(browser):
+ for done_sid in ("session-a", "session-b"):
+ result = _run(browser, "running", done_sid, mutated=True)
+ _assert_running_prerequisite(result)
+ with pytest.raises(AssertionError):
+ _assert_canonical_clear(result, done_sid)
+ assert result["after"]["state"]["phase"] == "running"
+ assert result["after"]["state"]["sessionId"] == done_sid
+ assert result["after"]["timerRunning"] is True
+ assert result["after"]["lock"] == "session-a"
+ assert result["after"]["cardsHtml"] != ""
+
+ for done_sid in ("session-a", "session-b"):
+ result = _run(browser, "non-running", done_sid, mutated=True)
+ assert result["after"]["state"]["phase"] == "done"
+ assert result["after"]["state"]["sessionId"] == done_sid
+
+ for kind in ("manual", "unowned", "stale-owner"):
+ result = _run(browser, kind, "session-b", mutated=True)
+ assert result["after"]["state"] == result["before"]["state"]