test: guard session-owned runtime invariants

This commit is contained in:
Michael Lam authored and test committed 2026-05-06 18:11:13 +00:00
1 parent 52be3e9b5c
commit 1f8e8f48ac
2 files changed
+158 -12

No files matched your search

+39 -12
View File
@@ -857,8 +857,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof _markSessionCompletedInList==='function'){
_markSessionCompletedInList(completedSession, activeSid);
}
stopApprovalPolling();
stopClarifyPolling();
stopApprovalPollingForSession(activeSid);
stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId || _clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
if(isActiveSession){
@@ -944,8 +944,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// TTS auto-read: speak the last assistant response if enabled (#499)
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus('');
setComposerStatus('');
renderSessionList();
if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id]){
_queueDrainSid=activeSid;
setBusy(false);
setStatus('');
setComposerStatus('');
}
playNotificationSound();
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
});
@@ -1026,7 +1031,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source.close();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
if(S.session&&S.session.session_id===activeSid){
@@ -1104,7 +1109,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'cancelled');
if(S.session&&S.session.session_id===activeSid){
@@ -1143,7 +1148,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const session=data&&data.session;
if(!session) return false;
if(session.active_stream_id||session.pending_user_message) return false;
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
_closeSource();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
@@ -1152,7 +1157,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){
_markSessionCompletionUnread(completedSid, session.message_count);
}
if(S.session&&S.session.session_id===activeSid){
const isActiveSession=_isSessionCurrentPane(activeSid);
if(isActiveSession){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
@@ -1179,7 +1185,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
syncTopbar();renderMessages({preserveScroll:true});
}
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');
renderSessionList();
if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id]){
_queueDrainSid=activeSid;
setBusy(false);
setComposerStatus('');
}
return true;
}catch(_){
return false;
@@ -1193,7 +1204,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);clearTimeout(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPollingForSession(activeSid);stopClarifyPollingForSession(activeSid);
_closeSource();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
@@ -1221,8 +1232,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
delete INFLIGHT[activeSid];
clearInflight();
clearInflightState(activeSid);
stopApprovalPolling();
stopClarifyPolling();
stopApprovalPollingForSession(activeSid);
stopClarifyPollingForSession(activeSid);
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(!_clarifySessionId||_clarifySessionId===activeSid) hideClarifyCard(true, 'terminal');
if(S.session&&S.session.session_id===activeSid){
@@ -1413,6 +1424,7 @@ async function respondApproval(choice) {
function startApprovalPolling(sid) {
stopApprovalPolling();
_approvalPollingSessionId = sid || null;
// ── SSE (preferred): long-lived connection, server pushes instantly ──
try {
const es = new EventSource(new URL('api/approval/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
@@ -1455,6 +1467,7 @@ function startApprovalPolling(sid) {
let _approvalEventSource = null;
let _approvalSSEHealthTimer = null;
let _approvalPollingSessionId = null;
function _startApprovalFallbackPoll(sid) {
_approvalPollTimer = setInterval(async () => {
@@ -1469,10 +1482,16 @@ function _startApprovalFallbackPoll(sid) {
}, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
}
function stopApprovalPollingForSession(sid) {
if(sid && _approvalPollingSessionId && _approvalPollingSessionId!==sid) return;
stopApprovalPolling();
}
function stopApprovalPolling() {
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
if (_approvalEventSource) { try { _approvalEventSource.close(); } catch(_){} _approvalEventSource = null; }
if (_approvalSSEHealthTimer) { clearInterval(_approvalSSEHealthTimer); _approvalSSEHealthTimer = null; }
_approvalPollingSessionId = null;
}
// ── Clarify polling ──
@@ -1770,9 +1789,11 @@ async function respondClarify(response) {
var _clarifyEventSource = null;
var _clarifyFallbackTimer = null;
var _clarifyHealthTimer = null;
let _clarifyPollingSessionId = null;
function startClarifyPolling(sid) {
stopClarifyPolling();
_clarifyPollingSessionId = sid || null;
_clarifyMissingEndpointWarned = false;
// SSE primary path: long-lived connection pushes events instantly.
@@ -1852,10 +1873,16 @@ function _startClarifyFallbackPoll(sid) {
}, 3000);
}
function stopClarifyPollingForSession(sid) {
if(sid && _clarifyPollingSessionId && _clarifyPollingSessionId!==sid) return;
stopClarifyPolling();
}
function stopClarifyPolling() {
if (_clarifyEventSource) { try { _clarifyEventSource.close(); } catch(_){} _clarifyEventSource = null; }
if (_clarifyFallbackTimer) { clearInterval(_clarifyFallbackTimer); _clarifyFallbackTimer = null; }
if (_clarifyHealthTimer) { clearInterval(_clarifyHealthTimer); _clarifyHealthTimer = null; }
_clarifyPollingSessionId = null;
}
// ── Notifications and Sound ──────────────────────────────────────────────────
@@ -0,0 +1,119 @@
"""Regression coverage for #1694 session-owned runtime invariants.
These source-level tests protect the existing vanilla-JS runtime boundary:
stream transports are keyed by stream_id/session_id, while the active pane is only
one projection. Background terminal events must update session/sidebar metadata
without tearing down the currently viewed pane's runtime state.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel: str) -> str:
return (REPO / rel).read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
idx = src.find(f"function {name}")
if idx == -1:
idx = src.find(f"async function {name}")
assert idx != -1, f"{name} not found"
brace = src.find("{", idx)
depth = 0
for pos in range(brace, len(src)):
ch = src[pos]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[idx : pos + 1]
raise AssertionError(f"{name} body did not terminate")
def _event_handler(src: str, event_name: str) -> str:
marker = f"source.addEventListener('{event_name}'"
idx = src.find(marker)
assert idx != -1, f"{event_name} handler not found"
next_handler = src.find("source.addEventListener(", idx + len(marker))
return src[idx:next_handler if next_handler != -1 else len(src)]
class TestSessionOwnedRuntimeInvariants:
def test_sidebar_cancel_uses_row_stream_id_not_active_pane_stream(self):
boot = read("static/boot.js")
body = _function_body(boot, "cancelSessionStream")
assert "session&&session.active_stream_id" in body, (
"Sidebar row cancellation must target the row-owned active_stream_id, "
"not the currently viewed pane's S.activeStreamId."
)
assert "S.activeStreamId" not in body[: body.index("if(S.session&&S.session.session_id===sid)")], (
"cancelSessionStream must not read or clear active-pane stream state until "
"it has proved the row session is the active pane."
)
def test_done_event_does_not_clear_unrelated_active_pane_busy_state(self):
messages = read("static/messages.js")
done = _event_handler(messages, "done")
unconditional = "_queueDrainSid=activeSid;renderSessionList();setBusy(false);setStatus('');"
assert unconditional not in done, (
"A background session's done event must not unconditionally call setBusy(false); "
"that can idle an unrelated active pane that is still running."
)
assert "if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in done.replace(" ", ""), (
"The done handler should only idle composer state when the completed stream "
"belongs to the active pane, or when no other active-pane inflight runtime exists."
)
def test_server_session_finalize_does_not_idle_unrelated_active_pane(self):
messages = read("static/messages.js")
finalize = _function_body(messages, "_restoreSettledSession")
assert "_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');" not in finalize, (
"The fallback server-finalize path must not idle the active pane for a "
"background session completion."
)
assert "if(isActiveSession||!S.session||!INFLIGHT[S.session.session_id])" in finalize.replace(" ", ""), (
"The fallback server-finalize path should use the same active-pane guard as the live done event."
)
def test_approval_and_clarify_pollers_are_stopped_by_owner_session(self):
messages = read("static/messages.js")
assert "let _approvalPollingSessionId = null" in messages
assert "let _clarifyPollingSessionId = null" in messages
assert "function stopApprovalPollingForSession" in messages
assert "function stopClarifyPollingForSession" in messages
approval_stop = _function_body(messages, "stopApprovalPollingForSession")
clarify_stop = _function_body(messages, "stopClarifyPollingForSession")
assert "_approvalPollingSessionId!==sid" in approval_stop.replace(" ", ""), (
"A terminal event for session A must not stop approval polling that now belongs to session B."
)
assert "_clarifyPollingSessionId!==sid" in clarify_stop.replace(" ", ""), (
"A terminal event for session A must not stop clarify polling that now belongs to session B."
)
done = _event_handler(messages, "done")
assert "stopApprovalPollingForSession(activeSid)" in done
assert "stopClarifyPollingForSession(activeSid)" in done
assert "stopApprovalPolling();\n stopClarifyPolling();" not in done, (
"The done handler must not blindly stop whatever approval/clarify poller "
"the active pane currently owns."
)
def test_live_stream_transport_and_inflight_state_remain_session_keyed(self):
messages = read("static/messages.js")
close_live = _function_body(messages, "closeLiveStream")
attach_start = messages.index("function attachLiveStream")
attach_live = messages[attach_start:messages.index("function _isActiveSession", attach_start)]
assert "constlive=LIVE_STREAMS[sessionId]" in close_live.replace(" ", ""), (
"LIVE_STREAMS must remain keyed by the owning session_id."
)
assert "constexistingLive=LIVE_STREAMS[activeSid]" in attach_live.replace(" ", ""), (
"attachLiveStream should reuse the session-owned live transport for the same stream."
)
assert re.search(r"INFLIGHT\[activeSid\].*messages", attach_live, re.DOTALL), (
"The browser-side inflight projection must remain keyed by the owning session_id."
)