diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ec9465..c8272042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Avoid duplicate sidebar rows when a compressed session completes after both the preserved snapshot id and continuation id are already present in the session list. + ## [v0.51.171] — 2026-05-30 — Release EQ (stage-batch53 — tool-output card badge + Neon opt-in skin) ### Added diff --git a/static/sessions.js b/static/sessions.js index 0c89c1da..bc5af517 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -361,7 +361,9 @@ function _markSessionCompletedInList(session, previousSid = null) { if (!session || !Array.isArray(_allSessions)) return; const finalSid = session.session_id || previousSid; if (!finalSid) return; - const idx = _allSessions.findIndex(s => s && (s.session_id === finalSid || s.session_id === previousSid)); + const finalIdx = _allSessions.findIndex(s => s && s.session_id === finalSid); + const previousIdx = previousSid ? _allSessions.findIndex(s => s && s.session_id === previousSid) : -1; + const idx = finalIdx >= 0 ? finalIdx : previousIdx; if (idx < 0) return; const {messages: _messages, tool_calls: _toolCalls, ...sessionMeta} = session; const messageCount = Number( @@ -384,6 +386,11 @@ function _markSessionCompletedInList(session, previousSid = null) { _sessionStreamingById.set(finalSid, false); _forgetObservedStreamingSession(finalSid); if (previousSid && previousSid !== finalSid) { + for (let i = _allSessions.length - 1; i >= 0; i--) { + if (i !== idx && _allSessions[i] && _allSessions[i].session_id === previousSid) { + _allSessions.splice(i, 1); + } + } _sessionStreamingById.delete(previousSid); _forgetObservedStreamingSession(previousSid); _sessionListSnapshotById.delete(previousSid); diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 2a62e935..2f885a88 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -100,7 +100,9 @@ def test_sidebar_cache_completion_handles_compression_session_rotation(): assert "function _markSessionCompletedInList(session, previousSid = null)" in helper_block assert "const finalSid = session.session_id || previousSid;" in helper_block - assert "s.session_id === finalSid || s.session_id === previousSid" in helper_block + assert "const finalIdx = _allSessions.findIndex(s => s && s.session_id === finalSid);" in helper_block + assert "const previousIdx = previousSid ? _allSessions.findIndex(s => s && s.session_id === previousSid) : -1;" in helper_block + assert "const idx = finalIdx >= 0 ? finalIdx : previousIdx;" in helper_block assert "const {messages: _messages, tool_calls: _toolCalls, ...sessionMeta} = session;" in helper_block assert "...sessionMeta" in helper_block assert "session_id: finalSid" in helper_block diff --git a/tests/test_sidebar_completion_dedupe.py b/tests/test_sidebar_completion_dedupe.py new file mode 100644 index 00000000..82d26c44 --- /dev/null +++ b/tests/test_sidebar_completion_dedupe.py @@ -0,0 +1,51 @@ +import json +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _extract_js_function(src: str, name: str) -> str: + start = src.index(f"function {name}(") + brace = src.index("{", start) + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[start : idx + 1] + raise AssertionError(f"could not extract {name}") + + +def test_mark_session_completed_in_list_dedupes_old_and_new_sid_rows(): + sessions_src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + fn_src = _extract_js_function(sessions_src, "_markSessionCompletedInList") + script = f""" +let _allSessions = [ + {{ session_id: 'old', title: 'Before', message_count: 10, pre_compression_snapshot: true }}, + {{ session_id: 'new', title: 'After', message_count: 2, parent_session_id: 'old' }}, +]; +let _sessionStreamingById = new Map([['old', true], ['new', true]]); +let _sessionListSnapshotById = new Map([['old', {{message_count: 10}}]]); +function _forgetObservedStreamingSession(_sid) {{}} +function renderSessionListFromCache() {{}} +{fn_src} +_markSessionCompletedInList({{ + session_id: 'new', + title: 'After done', + message_count: 3, + parent_session_id: 'old', +}}, 'old'); +console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreamingById.has('old')}})); +""" + result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) + payload = json.loads(result.stdout) + + assert [row["session_id"] for row in payload["rows"]] == ["new"] + assert payload["rows"][0]["title"] == "After done" + assert payload["rows"][0]["message_count"] == 3 + assert payload["oldStreaming"] is False