From 59ffe4fca5c3eb21e9df90ab24f0de4d8cd18c76 Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Thu, 21 May 2026 21:11:56 +0800 Subject: [PATCH 1/4] 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 2/4] 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 90dfbf2f2dffc37537668667ece6ddd9d42c5a5c Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Mon, 25 May 2026 15:16:26 +0800 Subject: [PATCH 3/4] 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 4/4] 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...',