From 59ffe4fca5c3eb21e9df90ab24f0de4d8cd18c76 Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Thu, 21 May 2026 21:11:56 +0800 Subject: [PATCH 01/12] fix: geist-contrast skin composer UI improvements - Light mode: override white user-bubble-text so textarea text is black (#111) - Remove scrollbar from textarea (scrollbar-width:none + webkit) - Remove double border on focus: split composer-box:focus-within from textarea:focus to prevent stacking box-shadows - Remove composer-box border (border:none) to eliminate double-border ring --- static/style.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/static/style.css b/static/style.css index 9ddc7cc0..d7c62b64 100644 --- a/static/style.css +++ b/static/style.css @@ -347,7 +347,7 @@ :root[data-skin="geist-contrast"] .tool-card, :root[data-skin="geist-contrast"] .msg-body pre, :root[data-skin="geist-contrast"] .preview-md pre, - :root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border-color:var(--border)!important;box-shadow:none!important;} + :root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border:none!important;box-shadow:none!important;} :root[data-skin="geist-contrast"] .session-item, :root[data-skin="geist-contrast"] .nav-tab, :root[data-skin="geist-contrast"] .rail-btn, @@ -440,10 +440,10 @@ :root[data-skin="geist-contrast"] button.send-btn:disabled{background:var(--surface-subtle)!important;border-color:var(--border)!important;color:var(--muted)!important;opacity:1!important;} :root.dark[data-skin="geist-contrast"] button.send-btn:disabled svg, :root.dark[data-skin="geist-contrast"] button.send-btn:disabled [data-lucide]{color:var(--muted)!important;stroke:var(--muted)!important;} + :root[data-skin="geist-contrast"] .composer-box:focus-within{border-color:transparent!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;} :root[data-skin="geist-contrast"] input:focus, :root[data-skin="geist-contrast"] textarea:focus, - :root[data-skin="geist-contrast"] select:focus, - :root[data-skin="geist-contrast"] .composer-box:focus-within, + :root[data-skin="geist-contrast"] select:focus{border-color:var(--accent)!important;box-shadow:none!important;outline:none!important;} :root[data-skin="geist-contrast"] .app-dialog-input:focus, :root[data-skin="geist-contrast"] .sidebar-search input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;} :root[data-skin="geist-contrast"] .logo, @@ -463,6 +463,12 @@ :root[data-skin="geist-contrast"] .sidebar-date-header.pinned{color:var(--accent-text)!important;} :root[data-skin="geist-contrast"]::-webkit-scrollbar-thumb{background:var(--border2)!important;} :root[data-skin="geist-contrast"] ::selection{background:var(--accent-bg-strong);color:var(--strong);} + /* ── Geist Contrast: composer fixes ── */ + /* Light mode: override white user-bubble-text so textarea text is black */ + :root[data-skin="geist-contrast"]:not(.dark){--user-bubble-text:#111111;} + /* Remove scrollbar from textarea */ + :root[data-skin="geist-contrast"] textarea#msg{scrollbar-width:none;overflow-y:auto;} + :root[data-skin="geist-contrast"] textarea#msg::-webkit-scrollbar{display:none;} /* #594: app-dialog light mode overrides — base styles use hardcoded dark gradients */ :root:not(.dark) .app-dialog{ From 7aae822872029fd535d1d606392785063cf49f74 Mon Sep 17 00:00:00 2001 From: Simonas Jakubonis Date: Sat, 23 May 2026 21:31:07 +0300 Subject: [PATCH 02/12] fix(compression): ignore tool output for compaction cards --- api/compression_anchor.py | 7 +++- api/streaming.py | 12 +----- ...st_issue2028_compression_anchor_helpers.py | 42 ++++++++++++++++++- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/api/compression_anchor.py b/api/compression_anchor.py index f251851c..12d96415 100644 --- a/api/compression_anchor.py +++ b/api/compression_anchor.py @@ -53,7 +53,7 @@ def _content_has_part_type(content, part_types): ) -def _is_context_compression_marker(message): +def is_context_compression_marker(message): """Return true for synthetic compression/reference cards, not user turns.""" if not isinstance(message, dict): return False @@ -71,6 +71,11 @@ def _is_context_compression_marker(message): ) +def _is_context_compression_marker(message): + """Backward-compatible alias for callers that have not switched yet.""" + return is_context_compression_marker(message) + + def visible_messages_for_anchor(messages, *, auto_compression: bool = False): """Return transcript messages that can anchor compression UI metadata. diff --git a/api/streaming.py b/api/streaming.py index c07f654a..1c72f13a 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -35,7 +35,7 @@ from api.config import ( load_settings, ) from api.helpers import redact_session_data, _redact_text -from api.compression_anchor import visible_messages_for_anchor +from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor from api.metering import meter from api.run_journal import RunJournalWriter from api.turn_journal import append_turn_journal_event_for_stream @@ -2251,15 +2251,7 @@ def _dedupe_replayed_active_context(previous_context, result_messages): def _is_context_compression_marker(msg): - if not isinstance(msg, dict): - return False - text = _message_text(msg.get('content', '')).lower() - return ( - 'context compaction' in text - or 'context compression' in text - or 'context was auto-compressed' in text - or 'active task list was preserved across context compression' in text - ) + return is_context_compression_marker(msg) def _compact_summary_text(raw_text: str | None, limit: int = 320) -> str | None: diff --git a/tests/test_issue2028_compression_anchor_helpers.py b/tests/test_issue2028_compression_anchor_helpers.py index 1fcb4f6a..d675d2a0 100644 --- a/tests/test_issue2028_compression_anchor_helpers.py +++ b/tests/test_issue2028_compression_anchor_helpers.py @@ -4,7 +4,8 @@ Regression coverage for shared compression-anchor visibility helpers (#2028). from pathlib import Path -from api.compression_anchor import visible_messages_for_anchor +from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor +from api.streaming import _compression_summary_from_messages, _is_context_compression_marker def test_legacy_duplicate_anchor_helpers_are_removed(): @@ -57,3 +58,42 @@ def test_visible_messages_for_anchor_keeps_manual_user_messages_simple(): [user_tool_metadata, user_attachment, assistant_tool_metadata], auto_compression=True, ) == [user_tool_metadata, user_attachment, assistant_tool_metadata] + + +def test_context_compression_marker_detection_is_prefix_and_role_scoped(): + real_marker = { + "role": "assistant", + "content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.", + } + preserved_tasks_marker = { + "role": "user", + "content": "[Your active task list was preserved across context compression] - [ ] follow up", + } + tool_noise = { + "role": "tool", + "content": "{\"description\": \"Troubleshoot frequent context compression indicators\"}", + } + user_discussion = { + "role": "user", + "content": "Why do I see context compression after every message?", + } + + assert is_context_compression_marker(real_marker) + assert is_context_compression_marker(preserved_tasks_marker) + assert _is_context_compression_marker(real_marker) + assert not is_context_compression_marker(tool_noise) + assert not is_context_compression_marker(user_discussion) + + +def test_compression_summary_ignores_tool_output_that_mentions_compression(): + marker = { + "role": "assistant", + "content": "[CONTEXT COMPACTION — REFERENCE ONLY] Keep this handoff as reference.", + } + skill_tool_output = { + "role": "tool", + "content": "{\"name\": \"hermes-webui-operations\", \"content\": \"Troubleshooting frequent context compression indicators...\"}", + } + + assert _compression_summary_from_messages([marker, skill_tool_output]) == marker["content"] + assert _compression_summary_from_messages([skill_tool_output]) is None From 3009c0bf71db2fa4a2bcc7c49b58ce7cd781c967 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Mon, 25 May 2026 08:28:12 +0200 Subject: [PATCH 03/12] fix(chat): keep compression tip selected in sidebar --- CHANGELOG.md | 4 +++ static/sessions.js | 7 +++++ tests/test_session_lineage_collapse.py | 42 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42919130..80a976a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Sidebar compression lineage collapse now prefers the current continuation tip over a preserved parent snapshot when both rows share the same backend segment count. This keeps reloads after context compression from reopening the older parent transcript and making the active conversation appear to disappear. + ## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults) ### Fixed diff --git a/static/sessions.js b/static/sessions.js index 83d8c5c4..e25cf812 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2817,6 +2817,13 @@ function _collapseSessionLineageForSidebar(sessions){ if(bSeg||aSeg){ if(bSeg!==aSeg) return bSeg-aSeg; } + // Preserved pre-compression parents can share the same backend segment + // count as the continuation. Prefer the non-snapshot tip before falling + // back to timestamps, otherwise a recently-polled parent reopens the + // older transcript and makes the active continuation look lost. + const bSnapshot=!!(b&&b.pre_compression_snapshot); + const aSnapshot=!!(a&&a.pre_compression_snapshot); + if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot; return _sessionTimestampMs(b)-_sessionTimestampMs(a); }); const chosen=sorted[0]; diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 6fba937c..2550375c 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -253,6 +253,48 @@ console.log(JSON.stringify(collapsed)); +def test_sidebar_lineage_collapse_prefers_current_tip_over_same_segment_snapshot(): + """A preserved parent snapshot can share the child's backend segment count. + + Loading/polling the parent refreshes its timestamp, but the collapsed row + must still open the non-snapshot continuation tip. Otherwise a reload after + compression jumps back to the older parent transcript and looks like the + active conversation disappeared. + """ + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_sessionLineageKey')); +eval(extractFunc('_collapseSessionLineageForSidebar')); +const sessions = [ + {{session_id:'parent', title:'Duplicate Assistant Text Blocks', message_count:64, updated_at:300, last_message_at:300, pre_compression_snapshot:true, _lineage_root_id:'parent', _compression_segment_count:2}}, + {{session_id:'child', title:'Duplicate Assistant Text Blocks', parent_session_id:'parent', message_count:86, updated_at:200, last_message_at:200, _lineage_root_id:'parent', _compression_segment_count:2}}, +]; +const collapsed = _collapseSessionLineageForSidebar(sessions); +console.log(JSON.stringify(collapsed)); +""" + collapsed = json.loads(_run_node(source)) + assert [row["session_id"] for row in collapsed] == ["child"] + assert collapsed[0]["_lineage_collapsed_count"] == 2 + assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["child", "parent"] + + + def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" From 9e74072bf3c38e2ddb254e883de1073bdb0a3ea9 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Mon, 25 May 2026 08:47:53 +0200 Subject: [PATCH 04/12] fix(chat): resolve stale compression parent routes --- CHANGELOG.md | 1 + static/sessions.js | 37 +++++++++++++++++++++++ tests/test_session_lineage_collapse.py | 41 ++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a976a6..0f55376f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed - Sidebar compression lineage collapse now prefers the current continuation tip over a preserved parent snapshot when both rows share the same backend segment count. This keeps reloads after context compression from reopening the older parent transcript and making the active conversation appear to disappear. +- Reloading a stale `/session/` compression URL now resolves to the visible continuation tip from the sidebar payload instead of reopening the archived parent snapshot. ## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults) diff --git a/static/sessions.js b/static/sessions.js index e25cf812..c04d7a52 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -543,6 +543,10 @@ async function newSession(flash, options={}){ async function loadSession(sid){ const opts = arguments[1] || {}; + if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){ + const resolvedSid=_resolveSessionIdFromSidebarLineage(sid); + if(resolvedSid&&resolvedSid!==sid) sid=resolvedSid; + } const forceReload = !!opts.force; const currentSid = S.session ? S.session.session_id : null; // Clicking the already-open session in the sidebar is a no-op. Reloading it @@ -2640,6 +2644,39 @@ function _sessionLineageContainsSession(s, sid){ return false; } +function _resolveSessionIdFromSidebarLineage(sid){ + sid=String(sid||'').trim(); + if(!sid||!Array.isArray(_allSessions)||!_allSessions.length) return sid||null; + const visibleRows=_collapseSessionLineageForSidebar(_allSessions).filter(row=>row&&!_isChildSession(row)); + if(visibleRows.some(row=>row&&row.session_id===sid)) return sid; + const candidates=[]; + for(const row of visibleRows){ + if(!row||!row.session_id) continue; + if(row.session_source==='fork'||row.relationship_type==='child_session') continue; + const lineageLike=!!( + row._lineage_key||row._lineage_root_id||row.lineage_root_id|| + row._compression_segment_count||row.pre_compression_snapshot|| + (Array.isArray(row._lineage_segments)&&row._lineage_segments.length>1) + ); + if(!lineageLike) continue; + const key=_sidebarLineageKeyForRow(row); + if(key===sid||row.parent_session_id===sid||row._lineage_root_id===sid||row.lineage_root_id===sid||_sessionLineageContainsSession(row,sid)){ + candidates.push(row); + } + } + if(!candidates.length) return sid; + candidates.sort((a,b)=>{ + const bSeg=Number(b&&b._compression_segment_count||b&&b._lineage_collapsed_count||0); + const aSeg=Number(a&&a._compression_segment_count||a&&a._lineage_collapsed_count||0); + if(bSeg!==aSeg) return bSeg-aSeg; + const bSnapshot=!!(b&&b.pre_compression_snapshot); + const aSnapshot=!!(a&&a.pre_compression_snapshot); + if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot; + return _sessionTimestampMs(b)-_sessionTimestampMs(a); + }); + return candidates[0].session_id||sid; +} + function _sessionSegmentCount(s){ if(!s) return 0; const counts=[]; diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 2550375c..680d77fb 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -295,6 +295,47 @@ console.log(JSON.stringify(collapsed)); +def test_direct_parent_restore_resolves_to_visible_compression_tip(): + """A stale /session/ URL should reopen the visible continuation tip. + + The sidebar payload may omit the archived pre-compression parent but still + include the latest continuation with lineage metadata pointing back to the + parent. Boot restore should use that visible tip instead of loading the old + parent transcript and making the continuation look lost. + """ + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +var _allSessions = [ + {{session_id:'child', title:'Duplicate Assistant Text Blocks', parent_session_id:'parent', message_count:86, updated_at:200, last_message_at:200, _lineage_root_id:'parent', _compression_segment_count:2}}, + {{session_id:'other', title:'Other', message_count:4, updated_at:100, last_message_at:100}}, +]; +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_sessionLineageKey')); +eval(extractFunc('_sessionLineageContainsSession')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_collapseSessionLineageForSidebar')); +eval(extractFunc('_resolveSessionIdFromSidebarLineage')); +console.log(JSON.stringify({{parent:_resolveSessionIdFromSidebarLineage('parent'), child:_resolveSessionIdFromSidebarLineage('child'), other:_resolveSessionIdFromSidebarLineage('other')}})); +""" + result = json.loads(_run_node(source)) + assert result == {"parent": "child", "child": "child", "other": "other"} + + def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" From 90dfbf2f2dffc37537668667ece6ddd9d42c5a5c Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Mon, 25 May 2026 15:16:26 +0800 Subject: [PATCH 05/12] fix: marker-based compression anchor calculation Instead of using len(visible_after)-1 (which points to the last visible message and gets pushed behind the render window as more turns accumulate), find the last [CONTEXT COMPACTION] marker in s.messages and compute the anchor from visible messages before it. This keeps the compression reference card at the correct boundary even after 50+ subsequent turns have scrolled the render window past the old anchor position. Fixes a bug where the assistant's output message appeared to disappear after automatic context compression because the reference card was placed at the wrong position. --- api/streaming.py | 53 +++++++++- static/messages.js | 32 ++++-- ...st_issue2028_compression_anchor_helpers.py | 98 +++++++++++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index 1c72f13a..d8e4c932 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4684,11 +4684,56 @@ def _run_agent_streaming( # Notify the frontend that compression happened if _compressed: visible_after = visible_messages_for_anchor(s.messages, auto_compression=True) - s.compression_anchor_visible_idx = ( - max(0, len(visible_after) - 1) if visible_after else None - ) + # Find the LAST [CONTEXT COMPACTION] marker in s.messages + # and count visible messages before it. This is the correct + # anchor — it points to the compression boundary regardless + # of how many turns have been added since the boundary was + # established. Using len(visible_before)-1 is fragile when + # _previous_messages doesn't include markers or when extra + # messages accumulate between compression and the done event. + _last_marker_raw_idx = None + for _mi, _m in enumerate(s.messages): + if _is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + if _last_marker_raw_idx is not None: + _visible_before_marker = visible_messages_for_anchor( + s.messages[:_last_marker_raw_idx], auto_compression=True, + ) + s.compression_anchor_visible_idx = max(0, len(_visible_before_marker) - 1) + logger.info( + '[ANCHOR-MARKER] session=%s marker_raw=%d vis_before=%d anchor=%d', + getattr(s, 'session_id', '?'), + _last_marker_raw_idx, + len(_visible_before_marker), + s.compression_anchor_visible_idx, + ) + else: + # Fallback: use pre-turn display messages + visible_before = visible_messages_for_anchor( + _previous_messages, auto_compression=True, + ) + if visible_before: + s.compression_anchor_visible_idx = max(0, len(visible_before) - 1) + elif visible_after: + s.compression_anchor_visible_idx = 0 + else: + s.compression_anchor_visible_idx = None + logger.info( + '[ANCHOR-FALLBACK] session=%s vis_before=%d anchor=%d', + getattr(s, 'session_id', '?'), + len(visible_before) if visible_before else 0, + s.compression_anchor_visible_idx if s.compression_anchor_visible_idx is not None else -1, + ) + # Pick anchor_msg for _compression_anchor_message_key + _anchor_vis_idx = s.compression_anchor_visible_idx + if _anchor_vis_idx is not None and visible_after and _anchor_vis_idx < len(visible_after): + anchor_msg = visible_after[_anchor_vis_idx] + elif visible_after: + anchor_msg = visible_after[-1] + else: + anchor_msg = None s.compression_anchor_message_key = ( - _compression_anchor_message_key(visible_after[-1]) if visible_after else None + _compression_anchor_message_key(anchor_msg) if anchor_msg else None ) s.compression_anchor_summary = _compact_summary_text( _compression_summary_from_messages(s.messages) diff --git a/static/messages.js b/static/messages.js index b5052fb8..2baf8ebc 100644 --- a/static/messages.js +++ b/static/messages.js @@ -570,10 +570,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // A same-stream transport can be reused unless the browser has already // marked it closed; closed streams must still fall through to reopen. (typeof EventSource==='undefined'||existingLive.source.readyState!==EventSource.CLOSED) - ){ - return; - } - closeOtherLiveStreams(activeSid); + ){ + window._compressionUi={...window._compressionUi, sessionId:d.session.session_id}; + } + // Compression is complete — clear the transient UI state so + // renderMessages() uses the session's anchor metadata (reference card) + // instead of the live compression card, allowing compaction marker + // messages to be properly handled. + window._compressionUi = null; + // Find the last assistant message closeLiveStream(activeSid); let assistantText=''; @@ -1649,6 +1654,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ){ window._compressionUi={...window._compressionUi, sessionId:d.session.session_id}; } + // Compression is complete — clear the transient UI state so + // renderMessages() uses the session's anchor metadata (reference card) + // instead of the live compression card, allowing compaction marker + // messages to be properly handled. + window._compressionUi = null; // Find the last assistant message once for both reasoning persistence and timestamp const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant'); // Persist reasoning trace so thinking card survives page reload @@ -1721,6 +1731,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); syncTopbar();renderMessages({preserveScroll:true}); + // Clear stale auto-compression UI state once the turn is fully + // committed, so subsequent renderMessages() calls do not project + // a ghost compression card or skip compaction marker messages. + if(window._compressionUi&&window._compressionUi.automatic){ + window._compressionUi=null; + if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); + } if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); loadDir('.'); // TTS auto-read: speak the last assistant response if enabled (#499) @@ -1787,13 +1804,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('compressing',e=>{ // Context auto-compression is starting. Surface the same calm running // compression card as manual /compress while the summarizer LLM call runs. - if(!S.session||S.session.session_id!==activeSid) return; + if(!S.session) return; + const currentSid=S.session.session_id; let d={}; try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; } - if(d.session_id&&d.session_id!==activeSid) return; + if(d.session_id&&d.session_id!==currentSid) return; if(typeof setCompressionUi==='function'){ const state={ - sessionId:activeSid, + sessionId:currentSid, phase:'running', automatic:true, message:d.message||'Auto-compressing context...', diff --git a/tests/test_issue2028_compression_anchor_helpers.py b/tests/test_issue2028_compression_anchor_helpers.py index d675d2a0..35334885 100644 --- a/tests/test_issue2028_compression_anchor_helpers.py +++ b/tests/test_issue2028_compression_anchor_helpers.py @@ -97,3 +97,101 @@ def test_compression_summary_ignores_tool_output_that_mentions_compression(): assert _compression_summary_from_messages([marker, skill_tool_output]) == marker["content"] assert _compression_summary_from_messages([skill_tool_output]) is None + + +def test_marker_based_anchor_calculation(): + """Verify that the marker-based anchor logic used in streaming.py's done + handler correctly computes compression_anchor_visible_idx from the last + [CONTEXT COMPACTION] marker position. This prevents the reference card + from being pushed behind the render window after subsequent turns.""" + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "first reply"}, + {"role": "tool", "content": "tool output"}, + {"role": "user", "content": "second question"}, + {"role": "assistant", "content": "second reply"}, + {"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] summary"}, + {"role": "user", "content": "third question"}, + {"role": "assistant", "content": "third reply"}, + {"role": "tool", "content": "more tool output"}, + {"role": "user", "content": "fourth question"}, + {"role": "assistant", "content": "fourth reply"}, + ] + + # Simulate the fix logic from streaming.py + _last_marker_raw_idx = None + for _mi, _m in enumerate(messages): + if is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + assert _last_marker_raw_idx == 5, "expected marker at index 5" + + _visible_before_marker = visible_messages_for_anchor( + messages[:_last_marker_raw_idx], auto_compression=True, + ) + anchor = max(0, len(_visible_before_marker) - 1) + # Visible before marker: 4 messages (hello, first reply, second question, second reply) + # tool at index 2 is filtered out. Anchor = 3 (last visible before marker) + assert anchor == 3, f"expected anchor 3, got {anchor}" + + # Verify the anchor points to the right message + full_vis = visible_messages_for_anchor(messages, auto_compression=True) + # full_vis: hello, first reply, second question, second reply, third question, third reply, fourth question, fourth reply = 8 + assert len(full_vis) == 8, f"expected 8, got {len(full_vis)}" + assert full_vis[anchor]["content"] == "second reply" + + # After additional turns, anchor stays at marker boundary + messages_extended = messages + [ + {"role": "user", "content": "fifth question"}, + {"role": "assistant", "content": "fifth reply"}, + {"role": "tool", "content": "hidden"}, + ] + full_vis_ext = visible_messages_for_anchor(messages_extended, auto_compression=True) + # The anchor should still point to the same position in full_vis_ext + assert full_vis_ext[anchor]["content"] == "second reply" + assert anchor < len(full_vis_ext) - 3 # anchor is not at the end + + +def test_marker_based_anchor_multiple_compressions(): + """With multiple compression markers, the anchor uses the LAST marker.""" + messages = [ + {"role": "user", "content": "A"}, + {"role": "assistant", "content": "B"}, + {"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] first"}, + {"role": "user", "content": "C"}, + {"role": "assistant", "content": "D"}, + {"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] second"}, + {"role": "user", "content": "E"}, + {"role": "assistant", "content": "F"}, + ] + # Find last marker + _last_marker_raw_idx = None + for _mi, _m in enumerate(messages): + if is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + assert _last_marker_raw_idx == 5 + + _visible_before_marker = visible_messages_for_anchor( + messages[:_last_marker_raw_idx], auto_compression=True, + ) + anchor = max(0, len(_visible_before_marker) - 1) + # Visible before last marker at raw[5]: messages[0:5] = [A, B, marker, C, D] + # After filtering: A, B, C, D = 4 visible. Anchor = 3 (last visible = D) + assert anchor == 3, f"expected anchor 3, got {anchor}" + assert _visible_before_marker[anchor]["content"] == "D" + + +def test_marker_based_anchor_fallback_when_no_marker(): + """When no compression marker exists, fall back to old behavior.""" + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "reply"}, + ] + _last_marker_raw_idx = None + for _mi, _m in enumerate(messages): + if is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + assert _last_marker_raw_idx is None + + # Should fall through (not crash) - verified by the old path + visible_after = visible_messages_for_anchor(messages, auto_compression=True) + assert len(visible_after) > 0 From 5b6e1e1477ece63940ff0a3e0d68a2f6b08f78bf Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Mon, 25 May 2026 15:27:57 +0800 Subject: [PATCH 06/12] fix: restore messages.js from upstream to fix inflight stream tests --- static/messages.js | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/static/messages.js b/static/messages.js index cb4fc858..f10b5d9d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -641,15 +641,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // A same-stream transport can be reused unless the browser has already // marked it closed; closed streams must still fall through to reopen. (typeof EventSource==='undefined'||existingLive.source.readyState!==EventSource.CLOSED) - ){ - window._compressionUi={...window._compressionUi, sessionId:d.session.session_id}; - } - // Compression is complete — clear the transient UI state so - // renderMessages() uses the session's anchor metadata (reference card) - // instead of the live compression card, allowing compaction marker - // messages to be properly handled. - window._compressionUi = null; - // Find the last assistant message + ){ + return; + } + closeOtherLiveStreams(activeSid); closeLiveStream(activeSid); let assistantText=''; @@ -1772,11 +1767,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ){ window._compressionUi={...window._compressionUi, sessionId:d.session.session_id}; } - // Compression is complete — clear the transient UI state so - // renderMessages() uses the session's anchor metadata (reference card) - // instead of the live compression card, allowing compaction marker - // messages to be properly handled. - window._compressionUi = null; // Find the last assistant message once for both reasoning persistence and timestamp const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant'); // Persist reasoning trace so thinking card survives page reload @@ -1857,13 +1847,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); syncTopbar();renderMessages({preserveScroll:true}); - // Clear stale auto-compression UI state once the turn is fully - // committed, so subsequent renderMessages() calls do not project - // a ghost compression card or skip compaction marker messages. - if(window._compressionUi&&window._compressionUi.automatic){ - window._compressionUi=null; - if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); - } if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); loadDir('.'); // TTS auto-read: speak the last assistant response if enabled (#499) @@ -1949,14 +1932,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('compressing',e=>{ // Context auto-compression is starting. Surface the same calm running // compression card as manual /compress while the summarizer LLM call runs. - if(!S.session) return; - const currentSid=S.session.session_id; + if(!S.session||S.session.session_id!==activeSid) return; let d={}; try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; } - if(d.session_id&&d.session_id!==currentSid) return; + if(d.session_id&&d.session_id!==activeSid) return; if(typeof setCompressionUi==='function'){ const state={ - sessionId:currentSid, + sessionId:activeSid, phase:'running', automatic:true, message:d.message||'Auto-compressing context...', From d920d4222af3d0f307c4ceadb1a94b88cba8dd1b Mon Sep 17 00:00:00 2001 From: MinhoJJang Date: Mon, 25 May 2026 18:28:43 +0900 Subject: [PATCH 07/12] Trim paginated session tool call payloads --- CHANGELOG.md | 4 +++ api/routes.py | 43 ++++++++++++++++++++++---- tests/test_session_tail_payload.py | 49 +++++++++++++++++++++++++++--- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42919130..5606cea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Trim session-level tool call payloads to the returned message window for paginated `/api/session` loads, so long tool-heavy sessions do not send historical tool call summaries during ordinary session switching. + ## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults) ### Fixed diff --git a/api/routes.py b/api/routes.py index 8140f3f9..b46ec00a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2146,6 +2146,23 @@ def _messages_include_tool_metadata(messages) -> bool: return False +def _tool_calls_for_message_window(tool_calls, start_idx: int, message_count: int) -> list: + """Keep session-level tool calls that point into a returned message window.""" + if not isinstance(tool_calls, list) or message_count <= 0: + return [] + end_idx = start_idx + message_count + filtered = [] + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + assistant_idx = tool_call.get("assistant_msg_idx") + if isinstance(assistant_idx, bool) or not isinstance(assistant_idx, int): + continue + if start_idx <= assistant_idx < end_idx: + filtered.append(tool_call) + return filtered + + def _merged_session_messages_for_display(session, cli_messages=None) -> list: """Return the message coordinate space exposed by ``GET /api/session``. @@ -4080,6 +4097,19 @@ def handle_get(handler, parsed) -> bool: _truncated_msgs = _all_msgs else: _truncated_msgs = [] + # Index of the first returned message in the full message array. + # Frontend uses this as cursor for scroll-to-top paging. + if load_messages and msg_before is not None: + _messages_offset = max(0, _before_idx - len(_truncated_msgs)) + elif load_messages: + _messages_offset = max(0, len(_all_msgs) - len(_truncated_msgs)) + else: + _messages_offset = 0 + _windowed_messages = ( + load_messages + and msg_limit is not None + and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs)) + ) # Resolve effective context_length with model-metadata fallback so # older sessions (pre-#1318) that have context_length=0 persisted # still render a meaningful indicator on load. Mirrors the @@ -4119,6 +4149,12 @@ def handle_get(handler, parsed) -> bool: # messages already carry per-message tool metadata. Avoid sending # the full historical list with a small tail window. _session_tool_calls = [] + elif _windowed_messages: + _session_tool_calls = _tool_calls_for_message_window( + _session_tool_calls, + _messages_offset, + len(_truncated_msgs), + ) _merged_message_count = _summary_message_count if _summary_message_count is not None else len(_all_msgs) _merged_last_message_at = _summary_last_message_at if _summary_last_message_at is not None else 0 if _summary_last_message_at is None and _all_msgs: @@ -4179,12 +4215,7 @@ def handle_get(handler, parsed) -> bool: else: _truncated = load_messages and msg_limit is not None and len(_all_msgs) > msg_limit raw["_messages_truncated"] = _truncated - # Index of the first returned message in the full message array. - # Frontend uses this as cursor for scroll-to-top paging. - if msg_before is not None: - raw["_messages_offset"] = max(0, _before_idx - len(_truncated_msgs)) - else: - raw["_messages_offset"] = max(0, len(_all_msgs) - len(_truncated_msgs)) + raw["_messages_offset"] = _messages_offset _t4 = _time.monotonic() if effective_model: raw["model"] = effective_model diff --git a/tests/test_session_tail_payload.py b/tests/test_session_tail_payload.py index 236551e6..da6cad85 100644 --- a/tests/test_session_tail_payload.py +++ b/tests/test_session_tail_payload.py @@ -12,7 +12,8 @@ class _FakeSession: self.model_provider = None self.messages = messages self.tool_calls = [ - {"name": "old-tool", "snippet": "historical snippet", "assistant_msg_idx": 0} + {"name": "old-tool", "snippet": "historical snippet", "assistant_msg_idx": 0}, + {"name": "visible-tool", "snippet": "visible snippet", "assistant_msg_idx": 1}, ] self.input_tokens = 0 self.output_tokens = 0 @@ -43,7 +44,7 @@ class _FakeSession: } -def _invoke(session): +def _invoke(session, query=None): import api.routes as routes captured = {} @@ -53,7 +54,9 @@ def _invoke(session): captured["status"] = status return data - parsed = urlparse("/api/session?session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=1") + if query is None: + query = "session_id=tail_payload_001&messages=1&resolve_model=0&msg_limit=1" + parsed = urlparse(f"/api/session?{query}") with patch("api.routes.get_session", return_value=session), \ patch("api.routes._clear_stale_stream_state", return_value=False), \ patch("api.routes._lookup_cli_session_metadata", return_value={}), \ @@ -80,7 +83,7 @@ def test_tail_window_omits_historical_tool_calls_when_messages_have_tool_metadat assert payload["_messages_truncated"] is True -def test_tail_window_keeps_session_tool_calls_for_legacy_messages_without_metadata(): +def test_tail_window_keeps_only_visible_session_tool_calls_for_legacy_messages_without_metadata(): session = _FakeSession([ {"role": "user", "content": "older"}, {"role": "assistant", "content": "visible legacy message"}, @@ -89,4 +92,42 @@ def test_tail_window_keeps_session_tool_calls_for_legacy_messages_without_metada payload = _invoke(session) assert payload["messages"] == [session.messages[-1]] + assert payload["tool_calls"] == [session.tool_calls[-1]] + + +def test_full_load_keeps_all_session_tool_calls_for_legacy_messages_without_metadata(): + session = _FakeSession([ + {"role": "user", "content": "older"}, + {"role": "assistant", "content": "visible legacy message"}, + ]) + + payload = _invoke( + session, + query="session_id=tail_payload_001&messages=1&resolve_model=0", + ) + + assert payload["messages"] == session.messages assert payload["tool_calls"] == session.tool_calls + + +def test_msg_before_window_keeps_only_that_page_session_tool_calls(): + session = _FakeSession([ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second legacy message"}, + {"role": "user", "content": "third"}, + {"role": "assistant", "content": "fourth legacy message"}, + ]) + session.tool_calls = [ + {"name": "first-page-tool", "snippet": "kept", "assistant_msg_idx": 1}, + {"name": "tail-tool", "snippet": "not in page", "assistant_msg_idx": 3}, + {"name": "unindexed-tool", "snippet": "cannot place"}, + ] + + payload = _invoke( + session, + query="session_id=tail_payload_001&messages=1&resolve_model=0&msg_before=3&msg_limit=2", + ) + + assert payload["messages"] == session.messages[1:3] + assert payload["tool_calls"] == [session.tool_calls[0]] + assert payload["_messages_offset"] == 1 From 459286830b9a1cd831b1641d754420bfac21aeea Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 25 May 2026 21:21:15 +0800 Subject: [PATCH 08/12] fix(session): preserve sidecar truncation boundary --- CHANGELOG.md | 4 ++ api/models.py | 45 +++++++++++- api/routes.py | 17 ++++- api/session_ops.py | 12 ++++ tests/test_issue2914_truncation_watermark.py | 76 ++++++++++++++++++++ 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue2914_truncation_watermark.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 42919130..2ae1a421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Undo, retry, and explicit session truncation now persist a sidecar truncation watermark, preventing older `state.db` rows from reappearing after the WebUI transcript was intentionally shortened. + ## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults) ### Fixed diff --git a/api/models.py b/api/models.py index fcd12ff5..c1767ba5 100644 --- a/api/models.py +++ b/api/models.py @@ -415,6 +415,7 @@ class Session: context_engine_state=None, context_length=None, threshold_tokens=None, last_prompt_tokens=None, + truncation_watermark=None, gateway_routing=None, gateway_routing_history=None, llm_title_generated: bool=False, parent_session_id: str=None, @@ -461,6 +462,7 @@ class Session: self.context_length = context_length self.threshold_tokens = threshold_tokens self.last_prompt_tokens = last_prompt_tokens + self.truncation_watermark = truncation_watermark self.gateway_routing = gateway_routing if isinstance(gateway_routing, dict) else None self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else [] self.llm_title_generated = bool(llm_title_generated) @@ -490,6 +492,18 @@ class Session: def path(self): return SESSION_DIR / f'{self.session_id}.json' + def _maybe_clear_truncation_watermark(self) -> None: + watermark = _message_timestamp_as_float({"timestamp": self.truncation_watermark}) + if watermark is None: + return + max_message_timestamp = None + for msg in self.messages or []: + timestamp = _message_timestamp_as_float(msg) + if timestamp is not None: + max_message_timestamp = timestamp if max_message_timestamp is None else max(max_message_timestamp, timestamp) + if max_message_timestamp is not None and max_message_timestamp > watermark: + self.truncation_watermark = None + def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None: # ── #1558 P0 guard ────────────────────────────────────────────── # Refuse to save a session that was loaded with metadata_only=True. @@ -510,6 +524,7 @@ class Session: ) if touch_updated_at: self.updated_at = time.time() + self._maybe_clear_truncation_watermark() # Write metadata fields first so load_metadata_only() can read them # without parsing the full messages array (which may be 400KB+). # Fields are listed in the order they should appear in the JSON file. @@ -525,6 +540,7 @@ class Session: 'context_engine', 'compression_anchor_engine', 'compression_anchor_mode', 'compression_anchor_details', 'context_engine_state', 'context_length', 'threshold_tokens', 'last_prompt_tokens', + 'truncation_watermark', 'gateway_routing', 'gateway_routing_history', 'llm_title_generated', 'parent_session_id', 'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at', @@ -3285,13 +3301,27 @@ def state_db_delta_after_context(sidecar_context: list, state_messages: list) -> return state_messages[best_len:] -def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list: +def merge_session_messages_append_only( + sidecar_messages: list, + state_messages: list, + *, + truncation_watermark=None, +) -> list: """Merge sidecar/context and state.db messages without deleting local rows.""" sidecar_messages = list(sidecar_messages or []) state_messages = list(state_messages or []) + watermark_timestamp = _message_timestamp_as_float({"timestamp": truncation_watermark}) if not state_messages: return sidecar_messages if not sidecar_messages: + if watermark_timestamp is not None: + return [ + msg for msg in state_messages + if ( + (timestamp := _message_timestamp_as_float(msg)) is not None + and timestamp <= watermark_timestamp + ) + ] return state_messages merged_messages = [] @@ -3336,6 +3366,13 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l skipped_state_visible_counts.get(matched_visible_key, 0) + 1 ) continue + if ( + watermark_timestamp is not None + and timestamp is not None + and timestamp > watermark_timestamp + and key not in seen_message_keys + ): + continue if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp: if key in seen_message_keys: continue @@ -3392,7 +3429,11 @@ def reconciled_state_db_messages_for_session( state_messages = get_state_db_session_messages(getattr(session, 'session_id', None)) if prefer_context and local_messages: state_messages = state_db_delta_after_context(local_messages, state_messages) - return merge_session_messages_append_only(local_messages, state_messages) + return merge_session_messages_append_only( + local_messages, + state_messages, + truncation_watermark=getattr(session, "truncation_watermark", None), + ) def get_cli_session_messages(sid) -> list: diff --git a/api/routes.py b/api/routes.py index 8140f3f9..e11fd86a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2202,7 +2202,11 @@ def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict sidecar_messages = getattr(sidecar_session, "messages", []) or [] state_db_messages = get_state_db_session_messages(sid, profile=profile) return _message_summary( - merge_session_messages_append_only(sidecar_messages, state_db_messages) + merge_session_messages_append_only( + sidecar_messages, + state_db_messages, + truncation_watermark=getattr(sidecar_session, "truncation_watermark", None), + ) ) @@ -4033,7 +4037,11 @@ def handle_get(handler, parsed) -> bool: # them chronologically and dedupe exact repeats. _all_msgs = _merged_session_messages_for_display(s, cli_messages) else: - _all_msgs = merge_session_messages_append_only(s.messages, state_db_messages) + _all_msgs = merge_session_messages_append_only( + s.messages, + state_db_messages, + truncation_watermark=getattr(s, "truncation_watermark", None), + ) else: if is_messaging_session and cli_messages: sidecar_messages = getattr(s, "messages", []) or [] @@ -5433,6 +5441,11 @@ def handle_post(handler, parsed) -> bool: keep = int(body["keep_count"]) with _get_session_agent_lock(body["session_id"]): s.messages = s.messages[:keep] + try: + from api.session_ops import _truncation_watermark_for + s.truncation_watermark = _truncation_watermark_for(s.messages) + except Exception: + s.truncation_watermark = 0.0 s.save() return j( handler, {"ok": True, "session": s.compact() | {"messages": s.messages}} diff --git a/api/session_ops.py b/api/session_ops.py index 5fc7a256..7bb405dc 100644 --- a/api/session_ops.py +++ b/api/session_ops.py @@ -27,6 +27,16 @@ def _truncate_at_last_user(messages): return history[:last_user_idx] +def _truncation_watermark_for(messages): + history = list(messages or []) + if not history: + return 0.0 + try: + return float(history[-1].get('timestamp') or 0) + except (AttributeError, TypeError, ValueError): + return 0.0 + + def retry_last(session_id: str) -> dict[str, Any]: """Truncate the session to before the last user message, return its text. @@ -75,6 +85,7 @@ def retry_last(session_id: str) -> dict[str, Any]: last_user_text = _extract_text(history[last_user_idx].get('content', '')) removed_count = len(history) - last_user_idx s.messages = history[:last_user_idx] + s.truncation_watermark = _truncation_watermark_for(s.messages) if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages: truncated_context = _truncate_at_last_user(s.context_messages) if truncated_context is not None: @@ -114,6 +125,7 @@ def undo_last(session_id: str) -> dict[str, Any]: removed_text = _extract_text(history[last_user_idx].get('content', '')) removed_count = len(history) - last_user_idx s.messages = history[:last_user_idx] + s.truncation_watermark = _truncation_watermark_for(s.messages) if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages: truncated_context = _truncate_at_last_user(s.context_messages) if truncated_context is not None: diff --git a/tests/test_issue2914_truncation_watermark.py b/tests/test_issue2914_truncation_watermark.py new file mode 100644 index 00000000..900d82c2 --- /dev/null +++ b/tests/test_issue2914_truncation_watermark.py @@ -0,0 +1,76 @@ +"""Regression tests for #2914 state.db tail replay after undo/retry/edit.""" +from __future__ import annotations + + +def _msg(role: str, content: str, ts: float, mid: str) -> dict: + return {"id": mid, "role": role, "content": content, "timestamp": ts} + + +def test_reconciled_messages_skip_state_tail_after_sidecar_truncation(): + from api.models import Session, reconciled_state_db_messages_for_session + + sidecar = [ + _msg("user", "first", 1.0, "sidecar-u1"), + _msg("assistant", "reply first", 2.0, "sidecar-a1"), + ] + state_db = [ + _msg("user", "first", 1.0, "state-u1"), + _msg("assistant", "reply first", 2.0, "state-a1"), + _msg("user", "second", 3.0, "state-u2"), + _msg("assistant", "reply second", 4.0, "state-a2"), + ] + session = Session( + session_id="issue2914", + messages=sidecar, + truncation_watermark=2.0, + ) + + merged = reconciled_state_db_messages_for_session(session, state_messages=state_db) + + assert [m["content"] for m in merged] == ["first", "reply first"] + + +def test_empty_sidecar_truncation_watermark_blocks_state_replay(): + from api.models import Session, reconciled_state_db_messages_for_session + + state_db = [ + _msg("user", "only prompt", 1.0, "state-u1"), + _msg("assistant", "only reply", 2.0, "state-a1"), + ] + session = Session( + session_id="issue2914empty", + messages=[], + truncation_watermark=0.0, + ) + + assert reconciled_state_db_messages_for_session(session, state_messages=state_db) == [] + + +def test_undo_persists_truncation_watermark_at_new_tail(monkeypatch, tmp_path): + import api.models as models + from api.models import Session + from api.session_ops import undo_last + + session_dir = tmp_path / "sessions" + session_dir.mkdir(parents=True) + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") + models.SESSIONS.clear() + + session = Session( + session_id="issue2914undo", + messages=[ + _msg("user", "first", 1.0, "u1"), + _msg("assistant", "reply first", 2.0, "a1"), + _msg("user", "second", 3.0, "u2"), + _msg("assistant", "reply second", 4.0, "a2"), + ], + ) + session.save() + + undo_last("issue2914undo") + + loaded = Session.load("issue2914undo") + assert loaded is not None + assert [m["content"] for m in loaded.messages] == ["first", "reply first"] + assert loaded.truncation_watermark == 2.0 From 3ee0173cd3f45c1d88f426f62e0f154849cf1848 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 25 May 2026 21:48:52 +0800 Subject: [PATCH 09/12] feat(server): allow extra CSP connect sources --- CHANGELOG.md | 4 ++ README.md | 1 + server.py | 65 +++++++++++++++----- tests/test_issue2901_csp_connect_extra.py | 74 +++++++++++++++++++++++ 4 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 tests/test_issue2901_csp_connect_extra.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 42919130..d4e65ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Added + +- Operators can now set `HERMES_WEBUI_CSP_CONNECT_EXTRA` to append validated extra origins to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments. + ## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults) ### Fixed diff --git a/README.md b/README.md index 26f18946..dbde203e 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,7 @@ Full list of environment variables: | `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace | | `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default | | `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication | +| `HERMES_WEBUI_CSP_CONNECT_EXTRA` | *(unset)* | Optional space-separated `http(s)://` or `ws(s)://` origins to append to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments | | `HERMES_WEBUI_EXTENSION_DIR` | *(unset)* | Optional local directory served at `/extensions/`; must point to an existing directory before extension injection is enabled | | `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) | | `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) | diff --git a/server.py b/server.py index f838d337..678e6422 100644 --- a/server.py +++ b/server.py @@ -5,6 +5,7 @@ All business logic lives in api/*. """ import logging import os +import re import socket import sys import time @@ -111,6 +112,55 @@ from urllib.parse import urlparse logger = logging.getLogger(__name__) +_CSP_CONNECT_BASE = ( + "'self' http://127.0.0.1:* http://localhost:* " + "ws://127.0.0.1:* ws://localhost:*" +) +_CSP_EXTRA_CONNECT_RE = re.compile( + r"^(?:https?|wss?)://(?:\*\.)?[A-Za-z0-9._~-]+(?::(?P\d{1,5}|\*))?$" +) + + +def _valid_csp_extra_connect_source(source: str) -> bool: + match = _CSP_EXTRA_CONNECT_RE.fullmatch(source) + if not match: + return False + port = match.group("port") + if not port or port == "*": + return True + try: + return 1 <= int(port) <= 65535 + except ValueError: + return False + + +def _csp_extra_connect_src() -> str: + raw = os.getenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", "").strip() + if not raw: + return "" + sources = raw.split() + if not sources or any(not _valid_csp_extra_connect_source(src) for src in sources): + logger.warning("Ignoring invalid HERMES_WEBUI_CSP_CONNECT_EXTRA value") + return "" + return " " + " ".join(sources) + + +def _build_csp_report_only_policy() -> str: + connect_src = _CSP_CONNECT_BASE + _csp_extra_connect_src() + return ( + "default-src 'self'; " + "base-uri 'self'; " + "object-src 'none'; " + "frame-ancestors 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data: blob:; " + "font-src 'self' data:; " + "media-src 'self' data: blob:; " + f"connect-src {connect_src}; " + "report-uri /api/csp-report; report-to csp-endpoint" + ) + from api.auth import check_auth from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j, get_profile_cookie @@ -207,24 +257,11 @@ class Handler(BaseHTTPRequestHandler): pass _ver_suffix = WEBUI_VERSION.removeprefix('v') server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI' - _CSP_REPORT_ONLY = ( - "default-src 'self'; " - "base-uri 'self'; " - "object-src 'none'; " - "frame-ancestors 'self'; " - "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " - "img-src 'self' data: blob:; " - "font-src 'self' data:; " - "media-src 'self' data: blob:; " - "connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; " - "report-uri /api/csp-report; report-to csp-endpoint" - ) _CSP_REPORT_TO = '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/api/csp-report"}]}' @classmethod def csp_report_only_policy(cls) -> str: - return cls._CSP_REPORT_ONLY + return _build_csp_report_only_policy() def end_headers(self) -> None: self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy()) diff --git a/tests/test_issue2901_csp_connect_extra.py b/tests/test_issue2901_csp_connect_extra.py new file mode 100644 index 00000000..349b9294 --- /dev/null +++ b/tests/test_issue2901_csp_connect_extra.py @@ -0,0 +1,74 @@ +"""Regression coverage for configurable CSP connect-src extras (#2901).""" + +from __future__ import annotations + + +def test_csp_connect_src_default_header_unchanged(monkeypatch): + from server import Handler + + monkeypatch.delenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", raising=False) + + policy = Handler.csp_report_only_policy() + + assert ( + "connect-src 'self' http://127.0.0.1:* http://localhost:* " + "ws://127.0.0.1:* ws://localhost:*; " + ) in policy + + +def test_csp_connect_src_includes_valid_extra_origins(monkeypatch): + from server import Handler + + monkeypatch.setenv( + "HERMES_WEBUI_CSP_CONNECT_EXTRA", + "https://metrics.example.com wss://events.example.com:443", + ) + + policy = Handler.csp_report_only_policy() + + assert ( + "connect-src 'self' http://127.0.0.1:* http://localhost:* " + "ws://127.0.0.1:* ws://localhost:* " + "https://metrics.example.com wss://events.example.com:443; " + ) in policy + + +def test_csp_connect_src_rejects_directive_injection(monkeypatch, caplog): + from server import Handler + + monkeypatch.setenv( + "HERMES_WEBUI_CSP_CONNECT_EXTRA", + "https://metrics.example.com; script-src *", + ) + + policy = Handler.csp_report_only_policy() + + assert "https://metrics.example.com" not in policy + assert "script-src *" not in policy + assert "Ignoring invalid HERMES_WEBUI_CSP_CONNECT_EXTRA" in caplog.text + + +def test_csp_connect_src_rejects_paths(monkeypatch): + from server import Handler + + monkeypatch.setenv( + "HERMES_WEBUI_CSP_CONNECT_EXTRA", + "https://metrics.example.com/api", + ) + + policy = Handler.csp_report_only_policy() + + assert "https://metrics.example.com/api" not in policy + + +def test_csp_connect_src_rejects_invalid_ports(monkeypatch): + from server import Handler + + monkeypatch.setenv( + "HERMES_WEBUI_CSP_CONNECT_EXTRA", + "https://metrics.example.com:99999", + ) + + policy = Handler.csp_report_only_policy() + + assert "https://metrics.example.com:99999" not in policy From 0f388de09ce3d5e8053882880ab8371a57cbd312 Mon Sep 17 00:00:00 2001 From: george-andraws <96165956+george-andraws@users.noreply.github.com> Date: Mon, 25 May 2026 10:06:45 -0700 Subject: [PATCH 10/12] fix duplicate chat upload filenames --- CHANGELOG.md | 1 + api/upload.py | 10 ++++++++++ tests/test_sprint1.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f2ab6d..8fed7db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Sidebar compression lineage collapse now prefers the current continuation tip over a preserved parent snapshot when both rows share the same backend segment count. This keeps reloads after context compression from reopening the older parent transcript and making the active conversation appear to disappear. - Reloading a stale `/session/` compression URL now resolves to the visible continuation tip from the sidebar payload instead of reopening the archived parent snapshot. - Undo, retry, and explicit session truncation now persist a sidecar truncation watermark, preventing older `state.db` rows from reappearing after the WebUI transcript was intentionally shortened. +- Chat uploads with the same filename in one session now keep distinct attachment files instead of overwriting the earlier upload. ## [v0.51.136] — 2026-05-25 — Release DH (stage-batch18 — 5-PR streaming + session index batch) diff --git a/api/upload.py b/api/upload.py index 87c14924..dc0aa0c1 100644 --- a/api/upload.py +++ b/api/upload.py @@ -81,6 +81,16 @@ def _upload_destination(session_id: str, safe_name: str) -> Path: dest = (dest_dir / safe_name).resolve() if not dest.is_relative_to(dest_dir): raise ValueError('Invalid upload destination') + if dest.exists(): + stem = dest.stem + suffix = dest.suffix + for idx in range(1, 1000): + candidate = (dest_dir / f'{stem}-{idx}{suffix}').resolve() + if not candidate.is_relative_to(dest_dir): + raise ValueError('Invalid upload destination') + if not candidate.exists(): + return candidate + raise ValueError('Too many uploads with the same filename') return dest diff --git a/tests/test_sprint1.py b/tests/test_sprint1.py index 9216818c..f11eb0e1 100644 --- a/tests/test_sprint1.py +++ b/tests/test_sprint1.py @@ -352,6 +352,24 @@ def test_upload_respects_attachment_dir_env(monkeypatch, tmp_path): assert _session_attachment_dir("session-123") == inbox.resolve() / "session-123" +def test_upload_destination_does_not_overwrite_same_filename(monkeypatch, tmp_path): + """Repeated uploads with the same filename in one session keep distinct paths.""" + from api.upload import _upload_destination + + inbox = tmp_path / "attachment-inbox" + monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(inbox)) + + first = _upload_destination("session-123", "photo.png") + first.write_bytes(b"first") + second = _upload_destination("session-123", "photo.png") + second.write_bytes(b"second") + + assert first.name == "photo.png" + assert second.name == "photo-1.png" + assert first.read_bytes() == b"first" + assert second.read_bytes() == b"second" + + def test_upload_too_large(cleanup_test_sessions): """Uploading a file over MAX_UPLOAD_BYTES is rejected (413 or connection closed).""" sid, _ = make_session_tracked(cleanup_test_sessions) From f13433b7d3259e4463ea71ccc4d77a8be4baa861 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 25 May 2026 17:48:43 +0000 Subject: [PATCH 11/12] stage-batch19: backfill CHANGELOG entry for #2915 (marker-based anchor) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fed7db5..99cb88ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Reloading a stale `/session/` compression URL now resolves to the visible continuation tip from the sidebar payload instead of reopening the archived parent snapshot. - Undo, retry, and explicit session truncation now persist a sidecar truncation watermark, preventing older `state.db` rows from reappearing after the WebUI transcript was intentionally shortened. - Chat uploads with the same filename in one session now keep distinct attachment files instead of overwriting the earlier upload. +- Compression reference card no longer disappears behind the "Load earlier messages" cutoff after subsequent turns. The post-compression anchor is now calculated from the position of the last `[CONTEXT COMPACTION]` marker in the transcript instead of pointing at the visible tail, so the anchor stays at the compression boundary regardless of how many turns have been added since. ## [v0.51.136] — 2026-05-25 — Release DH (stage-batch18 — 5-PR streaming + session index batch) From 4d3a59d72c4aac044b7ce08a734abf094d7faacf Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 25 May 2026 17:52:38 +0000 Subject: [PATCH 12/12] stage-batch19: stamp v0.51.137 / Release DI --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cb88ca..5af702c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] +## [v0.51.137] — 2026-05-25 — Release DI (stage-batch19 — 6-PR medium-risk batch) + ### Added - Operators can now set `HERMES_WEBUI_CSP_CONNECT_EXTRA` to append validated extra origins to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments.