From ea978a198931940aedf41db5b07caa70f6a01175 Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Tue, 19 May 2026 10:45:43 +0800 Subject: [PATCH] fix: surface auto-compression handoff --- CHANGELOG.md | 4 +++ api/streaming.py | 11 ++++++- static/messages.js | 5 ++- static/ui.js | 5 ++- tests/test_auto_compression_card.py | 50 +++++++++++++++++++++++++++-- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed15eec3..da548760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Surface automatic-compression handoff metadata through the `compressed` SSE event so the active browser stream keeps the completion card even after the backend rotates to a compressed continuation session. The event now carries both the origin session id and continuation id, and the automatic-compression detail line names the compressed session instead of silently dropping the done state. + ## [v0.51.91] — 2026-05-18 — Release BO (stage-384 — 5-PR full sweep batch — reasoning-replay history fix + archive-extract per-session inbox + fallback streaming warnings + sanitized custom-provider env hints + Slice 3c queue/goal adapter routing) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index c88da31f..5998bdbc 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4345,11 +4345,15 @@ def _run_agent_streaming( # reference stays alive even after the dict entry is removed. # Concurrent readers that already looked up the old ID will still # see the same Lock object until they release it. + _compression_origin_session_id = session_id + _compression_continuation_session_id = None _agent_sid = getattr(agent, 'session_id', None) _compressed = False if _agent_sid and _agent_sid != session_id: old_sid = session_id new_sid = _agent_sid + _compression_origin_session_id = old_sid + _compression_continuation_session_id = new_sid s.session_id = new_sid # Carry profile identity across the compression boundary. # Without this, s.profile stays None on the continuation @@ -4426,8 +4430,13 @@ def _run_agent_streaming( _compression_summary_from_messages(s.messages) or _compression_summary_from_messages(s.context_messages) ) + if _compression_continuation_session_id is None: + _compression_continuation_session_id = s.session_id put('compressed', { - 'session_id': s.session_id, + 'session_id': _compression_origin_session_id, + 'old_session_id': _compression_origin_session_id, + 'new_session_id': _compression_continuation_session_id, + 'continuation_session_id': _compression_continuation_session_id, 'message': 'Context auto-compressed to continue the conversation', 'usage': _live_usage_snapshot(), }) diff --git a/static/messages.js b/static/messages.js index 98c63daf..bd64fe7f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1809,7 +1809,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ 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!==activeSid) return; + const eventSid=d.old_session_id||d.session_id||activeSid; + if(eventSid!==activeSid) return; + const continuationSid=d.new_session_id||d.continuation_session_id||''; const message=String(d.message||'Context auto-compressed to continue the conversation').trim(); if(d.usage&&typeof _syncCtxIndicator==='function'){ S.lastUsage={...(S.lastUsage||{}),...d.usage}; @@ -1822,6 +1824,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ automatic:true, message, summary:{headline:message}, + continuationSessionId:continuationSid, }; setCompressionUi(state); const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state); diff --git a/static/ui.js b/static/ui.js index cb78ba09..91ad7ebd 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5015,7 +5015,10 @@ function _autoCompressionDetailText(state){ const running=state&&state.phase==='running'; const base=_autoCompressionBaseDetail(state); const elapsedLabel=running?_compressionElapsedLabel(state):''; - return elapsedLabel?`Elapsed: ${elapsedLabel}`:base; + if(running)return elapsedLabel?`Elapsed: ${elapsedLabel}`:base; + const continuation=String(state&&state.continuationSessionId||'').trim(); + const handoff=continuation?`Continued in compressed session: ${continuation}`:''; + return [base,handoff].filter(Boolean).join('\n'); } function _autoCompressionCardsHtml(state){ const running=state&&state.phase==='running'; diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index 8e4af44f..51dbd9e3 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -160,6 +160,19 @@ def test_auto_compression_running_detail_avoids_duplicate_message_text(): assert "${base}\\nElapsed:" not in helper +def test_auto_compression_done_detail_surfaces_continuation_handoff(): + src = _read("static/ui.js") + start = src.find("function _autoCompressionDetailText") + assert start != -1, "auto compression detail helper not found" + end = src.find("function _autoCompressionCardsHtml", start) + assert end != -1, "auto compression card helper not found after detail helper" + helper = src[start:end] + + assert "continuationSessionId" in helper + assert "Continued in compressed session" in helper + assert "return [base,handoff].filter(Boolean).join('\\n');" in helper + + def test_auto_compression_live_card_keeps_elapsed_state_for_timer_refresh(): src = _read("static/ui.js") start = src.find("function appendLiveCompressionCard") @@ -208,7 +221,22 @@ def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe(): assert guard in block assert block.index(guard) < block.index("setCompressionUi") assert "try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }" in block - assert "if(d.session_id&&d.session_id!==activeSid) return;" in block + assert "const eventSid=d.old_session_id||d.session_id||activeSid;" in block + assert "if(eventSid!==activeSid) return;" in block + + +def test_auto_compression_done_accepts_rotated_continuation_session_event(): + block = _compressed_listener_block() + + # Auto-compression can rotate the backend session id before the 'compressed' + # event is emitted. The browser stream still belongs to the pre-compression + # activeSid, so the listener must correlate on old_session_id and keep the + # continuation id as display metadata instead of dropping the event. + assert "const eventSid=d.old_session_id||d.session_id||activeSid;" in block + assert "const continuationSid=d.new_session_id||d.continuation_session_id||'';" in block + assert "if(eventSid!==activeSid) return;" in block + assert block.index("const eventSid=") < block.index("if(eventSid!==activeSid) return;") + assert "continuationSessionId:continuationSid" in block def test_auto_compression_done_sse_refreshes_context_indicator_usage(): @@ -228,10 +256,28 @@ def test_auto_compression_done_payload_includes_live_usage_snapshot(): assert end != -1, "compressed SSE payload end not found" block = src[start:end] - assert "'session_id': s.session_id" in block + assert "'session_id': _compression_origin_session_id" in block + assert "'old_session_id': _compression_origin_session_id" in block + assert "'new_session_id': _compression_continuation_session_id" in block + assert "'continuation_session_id': _compression_continuation_session_id" in block assert "'usage': _live_usage_snapshot()" in block +def test_auto_compression_rotation_tracks_origin_and_continuation_ids_for_sse(): + src = _read("api/streaming.py") + rotate_start = src.find("# ── Handle context compression side effects ──") + assert rotate_start != -1, "compression side-effect block not found" + rotate_end = src.find("# Stamp 'timestamp'", rotate_start) + assert rotate_end != -1, "compression side-effect block end not found" + block = src[rotate_start:rotate_end] + + assert "_compression_origin_session_id = session_id" in block + assert "_compression_continuation_session_id = None" in block + assert "_compression_origin_session_id = old_sid" in block + assert "_compression_continuation_session_id = new_sid" in block + assert "'new_session_id': _compression_continuation_session_id" in block + + def test_auto_compression_card_reuses_compression_card_renderer(): src = _read("static/ui.js") start = src.find("function _autoCompressionCardsHtml")