From 3a4259476d4747e9248bda0160fa93e2ace2bc5a Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 15 May 2026 09:20:19 +0200 Subject: [PATCH] fix: clear runtime fields on compression snapshots --- CHANGELOG.md | 4 ++ api/streaming.py | 37 +++++++++++++-- ...test_compression_snapshot_runtime_clear.py | 47 +++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 tests/test_compression_snapshot_runtime_clear.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 328683ef..572ebfeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Context-compression snapshot preservation now clears archived parent runtime fields (`active_stream_id`, `pending_user_message`, attachments, and start timestamp) before saving the old session id. This prevents a completed continuation session from leaving its parent looking permanently active/running after compression. + ### Added - **PR #2099** by @dobby-d-elf — Adds an opt-in `Settings → Preferences → Fade text effect` toggle (off by default). When enabled, newly streamed output tokens are revealed through an adaptive playout buffer and animated with an opacity-only fade similar to ChatGPT and other frontier LLM apps. Implementation details: fade locked per stream to avoid mid-stream toggle rewind; reduced-motion users get non-animated text; live cursor hidden while fade is active; custom renderer on `streaming-markdown` parser wraps only newly-appended words; animated spans replace themselves with plain text on `animationend` (no long-lived wrapper buildup in long responses); unsafe streamed `href`/`src` values blocked in fade renderer `set_attr` path. Performance tuning: 200ms base fade duration scaling to 350ms for fast output, 16ms word stagger, 320ms done-drain wait cap, 160 wps visual cap, max 2-3 words/frame, brief pauses after sentence punctuation. Default-off means existing users see no change. 293-line regression test pinning the contract. diff --git a/api/streaming.py b/api/streaming.py index 04cba4db..07e4f45f 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1862,6 +1862,37 @@ def _drop_checkpointed_current_user_from_context(messages, msg_text): return history +def _save_pre_compression_snapshot(session, old_session_id): + """Persist the archived pre-compression session without live turn state. + + During context compression the same ``Session`` object is reused for the new + continuation id. Before the final continuation save clears + ``active_stream_id`` and ``pending_*``, we also preserve an old-id snapshot so + the full pre-compression transcript remains recoverable. That archived + parent must not keep the current stream bookkeeping, otherwise the sidebar can + reopen the parent as a permanently running session while the child already + contains the completed answer. + """ + saved_sid = session.session_id + saved_active_stream_id = getattr(session, 'active_stream_id', None) + saved_pending_user_message = getattr(session, 'pending_user_message', None) + saved_pending_attachments = list(getattr(session, 'pending_attachments', []) or []) + saved_pending_started_at = getattr(session, 'pending_started_at', None) + session.session_id = old_session_id + session.active_stream_id = None + session.pending_user_message = None + session.pending_attachments = [] + session.pending_started_at = None + try: + session.save(touch_updated_at=False, skip_index=True) + finally: + session.session_id = saved_sid + session.active_stream_id = saved_active_stream_id + session.pending_user_message = saved_pending_user_message + session.pending_attachments = saved_pending_attachments + session.pending_started_at = saved_pending_started_at + + def _stream_writeback_is_current(session, stream_id): """Return True only while a worker still owns the session writeback. @@ -3651,18 +3682,14 @@ def _run_agent_streaming( # before save then restored after — but the on-disk # copy persisted with parent=None, breaking # fork-of-fork lineage traversal. (#2227 + #2223) - saved_sid = s.session_id - s.session_id = old_sid try: - s.save(touch_updated_at=False, skip_index=True) + _save_pre_compression_snapshot(s, old_sid) logger.info( "Preserved pre-compression session %s (%d messages) to disk", old_sid, len(s.messages), ) except Exception: logger.debug("Failed to preserve pre-compression session file", exc_info=True) - finally: - s.session_id = saved_sid except OSError: logger.debug("Could not read old session file before preservation") # Always link the continuation session to its immediate predecessor diff --git a/tests/test_compression_snapshot_runtime_clear.py b/tests/test_compression_snapshot_runtime_clear.py new file mode 100644 index 00000000..5c8b5b56 --- /dev/null +++ b/tests/test_compression_snapshot_runtime_clear.py @@ -0,0 +1,47 @@ +from api import streaming + + +class FakeSession: + def __init__(self): + self.session_id = "new_session" + self.parent_session_id = "original_parent" + self.active_stream_id = "live-stream" + self.pending_user_message = "current prompt" + self.pending_attachments = [{"name": "file.txt"}] + self.pending_started_at = 123.0 + self.messages = [{"role": "user", "content": "current prompt"}] + self.saved_payload = None + + def save(self, *, touch_updated_at=True, skip_index=False): + self.saved_payload = { + "session_id": self.session_id, + "parent_session_id": self.parent_session_id, + "active_stream_id": self.active_stream_id, + "pending_user_message": self.pending_user_message, + "pending_attachments": list(self.pending_attachments), + "pending_started_at": self.pending_started_at, + "touch_updated_at": touch_updated_at, + "skip_index": skip_index, + } + + +def test_pre_compression_snapshot_clears_runtime_fields_while_restoring_continuation_state(): + session = FakeSession() + + streaming._save_pre_compression_snapshot(session, "old_session") + + assert session.saved_payload == { + "session_id": "old_session", + "parent_session_id": "original_parent", + "active_stream_id": None, + "pending_user_message": None, + "pending_attachments": [], + "pending_started_at": None, + "touch_updated_at": False, + "skip_index": True, + } + assert session.session_id == "new_session" + assert session.active_stream_id == "live-stream" + assert session.pending_user_message == "current prompt" + assert session.pending_attachments == [{"name": "file.txt"}] + assert session.pending_started_at == 123.0