From 50c69713cc8de4d755058d4fb917ff475ab0fafb Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 21 May 2026 06:47:44 +0000 Subject: [PATCH] fix: reconcile state db delta after context --- api/models.py | 54 +++++++ pr-artifacts/context-replay-failure-cases.md | 143 ++++++++++++++++++ tests/test_issue1217_transcript_compaction.py | 131 ++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 pr-artifacts/context-replay-failure-cases.md diff --git a/api/models.py b/api/models.py index fd6161ec..909f3f61 100644 --- a/api/models.py +++ b/api/models.py @@ -3235,6 +3235,58 @@ def _has_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]) -> bool return _matching_visible_duplicate(visible_key, visible_keys) is not None +def state_db_delta_after_context(sidecar_context: list, state_messages: list) -> list: + """Return only state.db rows that are newer than model-facing context. + + `context_messages` is the authoritative model-facing prefix. state.db may + contain a mirrored copy of that prefix with fresh timestamps, especially for + LCM/continuation sessions. Appending the whole state transcript to a clean + sidecar context replays old context into the next runtime prompt. + """ + sidecar_context = list(sidecar_context or []) + state_messages = list(state_messages or []) + if not sidecar_context or not state_messages: + return state_messages + + sidecar_keys = [_session_message_content_key(m) for m in sidecar_context] + state_keys = [_session_message_content_key(m) for m in state_messages] + max_offset = min(len(sidecar_keys), len(state_keys)) + best_len = 0 + for offset in range(max_offset): + length = 0 + while ( + offset + length < len(sidecar_keys) + and length < len(state_keys) + and sidecar_keys[offset + length] == state_keys[length] + ): + length += 1 + if length > best_len: + best_len = length + + # Require at least two mirrored rows. A single repeated short user message + # is not enough evidence that state.db starts with a mirrored context + # segment, but small recovered contexts often contain only a compact summary + # and one follow-up row; those should still use the delta path. + if best_len < 2: + return state_messages + + sidecar_key_set = set(sidecar_keys) + last_represented_state_index = best_len - 1 + for idx, key in enumerate(state_keys[best_len:], start=best_len): + if key in sidecar_key_set: + last_represented_state_index = idx + + delta = [] + for msg, key in zip( + state_messages[last_represented_state_index + 1:], + state_keys[last_represented_state_index + 1:], + ): + if key in sidecar_key_set: + continue + delta.append(msg) + return delta + + def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list: """Merge sidecar/context and state.db messages without deleting local rows.""" sidecar_messages = list(sidecar_messages or []) @@ -3340,6 +3392,8 @@ def reconciled_state_db_messages_for_session( local_messages = getattr(session, 'messages', None) or [] if state_messages is None: 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) diff --git a/pr-artifacts/context-replay-failure-cases.md b/pr-artifacts/context-replay-failure-cases.md new file mode 100644 index 00000000..3901a459 --- /dev/null +++ b/pr-artifacts/context-replay-failure-cases.md @@ -0,0 +1,143 @@ +# Context replay / progress-ring failure cases + +This artifact records concrete failure cases observed while debugging WebUI + LCM context replay. It is intended to support an upstream PR with reproducible rationale. + +## A. Compression continuation replays active tail into display/context + +Observed shape after compression/continuation: + +```text +previous_context = [summary, A, B, C] +result_messages = previous_context + [A, B, C, D] +old saved context/display = [summary, A, B, C, A, B, C, D] +expected = [summary, A, B, C, D] +``` + +Code-level cause: writeback assumed `result_messages[len(previous_context):]` was all new delta. After LCM/session rollover, the agent may replay the active tail after the compacted prefix, so this assumption is false. + +Regression target: strip candidate prefixes that are already suffixes of existing context/display. + +## B. Near-duplicate large Session Arc Summary cards + +Observed in WebUI sidecar display transcript for sessions in the `20260520_200424_a43cef` / `20260520_201320_a95eac` lineage: + +```text +[Session Arc Summary (d1, node 39)] ... 62k chars +[Session Arc Summary (d1, node 39)] ... 62k chars +[Session Arc Summary (d1, node 39)] ... 74k chars +``` + +These summaries shared thousands of identical prefix characters but differed in tails/expand hints. Exact identity checks missed them. One duplicated ~80k char summary explains a ~20k-token jump. + +Regression target: treat large `[Session Arc Summary ...]` messages with the same long prefix as replayed summary artifacts. + +## C. Non-adjacent replay blocks separated by markers/summaries + +Observed display transcript contained repeated blocks that were not immediately adjacent, e.g. best block lengths around 171 messages in historical `messages`: + +```text +A B C ... [compression marker / summary / unrelated rows] ... A B C +``` + +Adjacent-only dedupe falsely reported clean. This matters because LCM/continuation can insert compression cards, cron banners, or summary messages between original block and replayed tail. + +Regression target: detect and strip replayed non-adjacent blocks when appending model context candidates. + +## D. Non-streaming `/api/chat` writeback missed dedupe + +Observed session: `20260521_060755_294aed`. + +User asked a short question with no meaningful new tool usage: + +```text +这是一个内部服务对么?简答 +``` + +Before cleanup: + +```text +context_messages: 136 +best replay: 67 messages repeated from index 0 at index 67 +last_prompt_tokens: 136668 (~53.4% of 256k) +``` + +Expected shape was: + +```text +previous_context(67) + new_user + new_assistant = 69 messages +``` + +Actual cause: streaming writeback used `_dedupe_replayed_context_messages`, but synchronous `/api/chat` wrote `_restore_reasoning_metadata(previous_context, result_messages)` directly to `s.context_messages`. + +Regression target: both streaming and non-streaming writeback paths must use the same replay-dedupe guard. + +## E. Runtime/progress-ring jump: clean persisted context, polluted turn-start reconciliation + +Observed session: `20260521_060755_294aed` after cleanup and deployment. + +Persisted sidecar after pause/cancel: + +```text +context_messages: 69 +context chars: 199,972 +rough content tokens: ~49,993 +last_prompt_tokens: 86,723 (~33.9%) +``` + +But starting/continuing a streaming turn made the progress ring jump to ~55%. Simulating the turn-start code path showed: + +```text +ctx_before_agent: 154 messages +chars: 448,438 +rough content tokens: ~112,109 +``` + +After applying existing final-writeback dedupe to that runtime prompt: + +```text +after_current_dedupe: 85 messages +chars: 248,466 +rough content tokens: ~62,116 +``` + +So the ring was not randomly wrong: it reflected a polluted runtime prompt estimate. The persisted sidecar stayed clean because final writeback/cancel did not save the runtime replay. + +Code-level cause: streaming turn start does: + +```python +_previous_context_messages = _new_turn_context_from_messages( + reconciled_state_db_messages_for_session( + s, + prefer_context=True, + state_messages=_external_state_messages, + ), + msg_text, +) +``` + +When `prefer_context=True`, sidecar `context_messages` are clean, but `state.db` still contains mirrored/replayed transcript rows. `reconciled_state_db_messages_for_session` append-only merges `context_messages + whole state transcript`, so the agent/runtime prompt temporarily receives old transcript rows again. + +Regression target: when `prefer_context=True` and sidecar `context_messages` exists, reconciliation must return: + +```text +clean sidecar context + truly newer state.db delta +``` + +not: + +```text +clean sidecar context + full state.db transcript +``` + +## PR thesis + +The bug family is not a model behavior issue. It is a WebUI persistence/reconciliation invariant violation: + +> Model-facing context is append-only, but append candidates may contain replayed context due to LCM/session continuation/state-db mirroring. Every boundary that merges result/state messages into model context must strip replayed prefixes/blocks and must distinguish clean sidecar context from full display/state transcripts. + +Key invariant for upstream: + +```text +If context_messages exists, it is the authoritative model-facing prefix. +State/db/display histories may be fuller/noisier and should only contribute messages that are demonstrably newer than that prefix. +``` diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index ab35a6d7..e45e9d11 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -371,6 +371,137 @@ def test_non_adjacent_replayed_context_block_is_not_appended_again(): ] + + +def test_prefer_context_reconcile_strips_state_db_mirrored_prefix(): + sidecar_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "[Session Arc Summary]"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "latest saved question", "timestamp": 200.0}, + {"role": "assistant", "content": "latest saved answer", "timestamp": 201.0}, + ] + state_messages = [ + {"role": "assistant", "content": "cron banner", "timestamp": 100.0}, + {"role": "user", "content": "[Session Arc Summary]", "timestamp": 101.0}, + {"role": "assistant", "content": "old answer", "timestamp": 102.0}, + {"role": "user", "content": "latest saved question", "timestamp": 200.0}, + {"role": "assistant", "content": "latest saved answer", "timestamp": 201.0}, + {"role": "user", "content": "continue", "timestamp": 300.0}, + ] + session = SimpleNamespace( + session_id="prefix-replay", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + [ + {"role": "user", "content": "continue", "timestamp": 300.0}, + ] + + +def test_prefer_context_reconcile_keeps_state_delta_when_no_mirrored_prefix(): + sidecar_context = [ + {"role": "user", "content": "summary"}, + {"role": "assistant", "content": "saved answer"}, + ] + state_messages = [ + {"role": "user", "content": "fresh question", "timestamp": 300.0}, + {"role": "assistant", "content": "fresh answer", "timestamp": 301.0}, + ] + session = SimpleNamespace( + session_id="fresh-delta", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + state_messages + + +def test_prefer_context_reconcile_strips_mirrored_rows_without_sidecar_timestamps(): + sidecar_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "summary"}, + {"role": "assistant", "content": "older answer"}, + {"role": "user", "content": "already saved"}, + ] + state_messages = [ + {"role": "assistant", "content": "cron banner", "timestamp": 100.0}, + {"role": "user", "content": "summary", "timestamp": 101.0}, + {"role": "assistant", "content": "older answer", "timestamp": 102.0}, + {"role": "user", "content": "already saved", "timestamp": 500.0}, + {"role": "user", "content": "new after sidecar", "timestamp": 600.0}, + ] + session = SimpleNamespace( + session_id="untimestamped-context", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + [ + {"role": "user", "content": "new after sidecar", "timestamp": 600.0}, + ] + + +def test_prefer_context_reconcile_starts_after_last_state_row_seen_in_context(): + sidecar_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "summary"}, + {"role": "assistant", "content": "old answer"}, + {"role": "assistant", "content": "later represented row"}, + ] + state_messages = [ + {"role": "assistant", "content": "cron banner", "timestamp": 100.0}, + {"role": "user", "content": "summary", "timestamp": 101.0}, + {"role": "assistant", "content": "old answer", "timestamp": 102.0}, + {"role": "tool", "content": "state-only stale tool", "timestamp": 103.0}, + {"role": "assistant", "content": "later represented row", "timestamp": 104.0}, + {"role": "user", "content": "new after represented boundary", "timestamp": 200.0}, + ] + session = SimpleNamespace( + session_id="represented-boundary", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + [ + {"role": "user", "content": "new after represented boundary", "timestamp": 200.0}, + ] + +def test_non_streaming_chat_writeback_dedupes_full_context_replay(): + previous_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "[Session Arc Summary (d1, node 39)]\n" + "old context\n" * 400}, + {"role": "assistant", "content": "previous answer"}, + ] + result_messages = previous_context + previous_context + [ + {"role": "user", "content": "simple follow-up"}, + {"role": "assistant", "content": "short answer"}, + ] + + next_context = _dedupe_replayed_context_messages(previous_context, result_messages) + + assert next_context == previous_context + [ + {"role": "user", "content": "simple follow-up"}, + {"role": "assistant", "content": "short answer"}, + ] + def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_path): messages = [ {"role": "user", "content": "legacy prompt"},