From 6ebd12c251bc2fca274ed49f577654557b688319 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Thu, 24 Sep 2026 14:55:27 +0000 Subject: [PATCH] fix(console): re-arm profile-paused approval/clarify pollers on focus/visibility --- static/messages.js | 25 +++++- ...test_console_noise_git_info_and_pollers.py | 79 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/static/messages.js b/static/messages.js index 3f32f009..ff496d19 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8137,8 +8137,11 @@ function startApprovalPolling(sid) { let _approvalEventSource = null; let _approvalSSEHealthTimer = null; let _approvalPollingSessionId = null; +// Session whose poller stopped on a profile-mismatch 409; re-armed on focus/visibility. +let _approvalProfilePausedSessionId = null; function _startApprovalFallbackPoll(sid) { + _approvalProfilePausedSessionId = null; // Run one tick immediately so a session already blocked on a pending approval // shows its card instantly (the removed SSE 'initial' event used to do this); // then poll on the 1500ms cadence. (#3913 SHOULD-FIX) @@ -8170,6 +8173,7 @@ function _startApprovalFallbackPoll(sid) { if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e) && _approvalPollTimer === pollTimer) { stopApprovalPolling(); + _approvalProfilePausedSessionId = sid; } } finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; } @@ -9300,6 +9304,7 @@ var _clarifyFallbackTimer = null; var _clarifyHealthTimer = null; let _clarifyFallbackPollInFlight = false; let _clarifyPollingSessionId = null; +let _clarifyProfilePausedSessionId = null; function startClarifyPolling(sid) { stopClarifyPolling(); @@ -9322,6 +9327,7 @@ function startClarifyPolling(sid) { function _startClarifyFallbackPoll(sid) { _clarifyPollingSessionId = sid || null; + _clarifyProfilePausedSessionId = null; // Run one tick immediately so a session already blocked on a pending clarify // shows its card instantly (the removed SSE 'initial' event used to do this); // then poll on the 3000ms cadence. (#3913 SHOULD-FIX) @@ -9356,7 +9362,10 @@ function _startClarifyFallbackPoll(sid) { // cookie, so polling can never succeed. Stop quietly; the live card stays standing. // Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor. if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) { - if (_clarifyFallbackTimer === pollTimer) stopClarifyPolling(); + if (_clarifyFallbackTimer === pollTimer) { + stopClarifyPolling(); + _clarifyProfilePausedSessionId = sid; + } return; } // A 404 from the active session domain is a STALE-SESSION signal — e.g. @@ -9427,6 +9436,20 @@ function stopClarifyPolling() { _clarifyPollingSessionId = null; } +// Another tab may have switched the shared profile cookie back: re-arm pollers that a +// profile-mismatch 409 paused while their session is still open. A still-mismatched +// profile just 409s once and pauses them again. +function _resumeProfilePausedPromptPollers() { + if (typeof document !== 'undefined' && document.hidden) return; + const current = (S.session && S.session.session_id) || null; + const approvalSid = _approvalProfilePausedSessionId; + const clarifySid = _clarifyProfilePausedSessionId; + if (approvalSid && approvalSid === current && !_approvalPollTimer) startApprovalPolling(approvalSid); + if (clarifySid && clarifySid === current && !_clarifyFallbackTimer) startClarifyPolling(clarifySid); +} +document.addEventListener('visibilitychange', _resumeProfilePausedPromptPollers); +window.addEventListener('focus', _resumeProfilePausedPromptPollers); + // ── Notifications and Sound ────────────────────────────────────────────────── function _completionNotificationPreviewText(lastAssistantMessage, options){ diff --git a/tests/test_console_noise_git_info_and_pollers.py b/tests/test_console_noise_git_info_and_pollers.py index cb36a92f..6132a49f 100644 --- a/tests/test_console_noise_git_info_and_pollers.py +++ b/tests/test_console_noise_git_info_and_pollers.py @@ -123,6 +123,9 @@ var _approvalFallbackPollInFlight = false, _approvalPollingSessionId = 'sid1'; var _clarifyEventSource = null, _clarifyFallbackTimer = null, _clarifyHealthTimer = null; var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false; var _approvalPendingBySession = new Map(); +var _approvalProfilePausedSessionId = null, _clarifyProfilePausedSessionId = null; +function _approvalPromptGeneration(){ return 0; } +function _clarifyPromptGeneration(){ return 0; } function _approvalPollingSessionMissingOrMismatched(sid){ return !sid || !S.session || S.session.session_id !== sid; } function _hideApprovalCardIfOwner(){ calls.hide++; } function _hideClarifyCardIfOwner(){ calls.hide++; } @@ -256,3 +259,79 @@ def test_stale_profile_mismatch_does_not_stop_replacement_poller(start_fn, stop_ assert r["polling"] == "sid1", "a late 409 from a replaced poller must not stop its successor" assert r["timerAlive"] is True assert r["inFlight"] is True, "the successor's in-flight guard must not be cleared by the stale request" + + +def _run_profile_round_trip(kind): + """Mismatch 409 pauses the poller; the cookie comes back, focus returns, and + the pending prompt for the still-open session must be fetched and shown.""" + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + approval = kind == "approval" + fns = [ + _extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"), + _extract_fn(MESSAGES_JS, "startApprovalPolling"), + _extract_fn(MESSAGES_JS, "_startApprovalFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopApprovalPollingForSession"), + _extract_fn(MESSAGES_JS, "stopApprovalPolling"), + _extract_fn(MESSAGES_JS, "startClarifyPolling"), + _extract_fn(MESSAGES_JS, "_startClarifyFallbackPoll"), + _extract_fn(MESSAGES_JS, "stopClarifyPollingForSession"), + _extract_fn(MESSAGES_JS, "stopClarifyPolling"), + _extract_fn(MESSAGES_JS, "_resumeProfilePausedPromptPollers"), + ] + harness = _HARNESS.replace("STATUS", "409").replace("BODY", json.dumps(_MISMATCH)) + harness = harness.replace("async function api(){", "var mismatch = true;\nasync function api(){\n if (!mismatch) { calls.api++; return {pending: {id: 'p1'}}; }") + harness = harness.replace("function showApprovalForSession(){}", "function showApprovalForSession(){ calls.shown = (calls.shown||0) + 1; }") + harness = harness.replace("function showClarifyForSession(){}", "function showClarifyForSession(){ calls.shown = (calls.shown||0) + 1; }") + start = "startApprovalPolling" if approval else "startClarifyPolling" + timer = "_approvalPollTimer" if approval else "_clarifyFallbackTimer" + script = harness + "\n".join(fns) + f""" +const tick = () => new Promise(r => setTimeout(r, 0)); +(async () => {{ + {start}('sid1'); + await tick(); + const stoppedOnMismatch = {timer} === null; + _resumeProfilePausedPromptPollers(); // focus while still mismatched + await tick(); + const stoppedAgain = {timer} === null; + const apiWhileMismatched = calls.api; + mismatch = false; // other tab switched the cookie back + _resumeProfilePausedPromptPollers(); // focus/visibility returns + await tick(); + process.stdout.write(JSON.stringify({{stoppedOnMismatch, stoppedAgain, apiWhileMismatched, + api: calls.api, shown: calls.shown || 0, running: {timer} !== null}})); +}})(); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +@pytest.mark.parametrize("kind", ["approval", "clarify"]) +def test_profile_round_trip_rearms_paused_poller_and_shows_pending(kind): + r = _run_profile_round_trip(kind) + assert r["stoppedOnMismatch"] is True + assert r["stoppedAgain"] is True, "a still-mismatched profile must stop the re-armed poller again" + assert r["apiWhileMismatched"] == 2 + assert r["api"] == 3, "the poller must fetch again once the profile is back" + assert r["shown"] == 1, "the pending card must be shown after the profile round-trip" + assert r["running"] is True + + +def test_resume_does_not_rearm_for_a_different_open_session(): + node = shutil.which("node") + if not node: + pytest.skip("node not installed") + script = _HARNESS.replace("STATUS", "409").replace("BODY", "{}") + _extract_fn( + MESSAGES_JS, "_resumeProfilePausedPromptPollers") + """ +var started = 0; +function startApprovalPolling(){ started++; } +function startClarifyPolling(){ started++; } +_approvalProfilePausedSessionId = 'old'; _clarifyProfilePausedSessionId = 'old'; +_resumeProfilePausedPromptPollers(); +process.stdout.write(String(started)); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + assert out.stdout == "0"