From 0fb46b81857020707c058ca2fb28e5fd8534f252 Mon Sep 17 00:00:00 2001 From: dsad <65560494+necoweb3@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:10:07 +0000 Subject: [PATCH] fix(agent): keep the compaction summary when the turn-end override rewrites the user row `_apply_persist_user_message_override` replaces the anchored user message's content with the clean transcript text. Its DB-write twin refuses to do that for a compaction-merged row, and says why: # Preflight compaction can re-anchor the override index at a message # whose content was MERGED with the compaction summary # (merge-summary-into-tail). Overwriting that with the clean gateway text # would silently drop the summary from the durable transcript. ... and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY) The live mutation has no such guard, and `finalize_turn` runs it FIRST -- it calls `_apply_persist_user_message_override(messages)` and only then `_persist_session(...)`. So the row is already clobbered by the time the DB-side guard looks at it, and the clobbered list is also what is returned as the continuation history the next turn is built from. The anchor lands on the merged row by design, not by accident: `reanchor_current_turn_user_idx` prefers the last user row whose content matches this turn's text and "fall[s] back to the last user message when no exact match survives (merge-summary-into-tail rewrites the content ...)" -- after a merge the exact match cannot survive, so the fallback selects exactly the merged row. Effect: on the turn a compaction fires, the `[MERGED PRIOR CONTEXT]` summary -- the entire pre-compaction conversation -- is deleted from the history the session continues from. It is silent, there is no error, and the archived pre-compaction turns are already rotated away. The durable child row still has the summary, so the live session and a later `/resume` now replay different bytes for the same row. Add the same guard to the live path. The paired timestamp override is unrelated and still applies. Both halves of this invariant are now covered: `TestFlushCompressedSummaryOverrideGuard` already asserted it for the DB write; the two new tests assert it for the in-memory path, plus a negative control that an ordinary turn is still cleaned in place. --- run_agent.py | 19 +++++++- tests/agent/test_api_content_sidecar.py | 61 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 4aa3b8332ef1..166ce7dfb97e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1762,8 +1762,23 @@ class AIAgent: # blocks. A list override, however, is the original clean # multimodal payload (for example before a queued /model note) # and must replace the API-local list once the turn is final. - if override is not None and ( - not isinstance(msg.get("content"), list) or isinstance(override, list) + # Preflight compaction can re-anchor this index at a message + # whose content was MERGED with the compaction summary + # (merge-summary-into-tail). That is not an accident: + # ``reanchor_current_turn_user_idx`` falls back to the last + # user row precisely BECAUSE the merge rewrote the content and + # the exact-match lookup misses. Overwriting it with the clean + # text would drop the summary from the continuation history the + # next turn is built from — the same hazard the DB-write twin + # below already refuses (see the sibling guard in + # ``_flush_messages_to_session_db_unlocked``). + if ( + override is not None + and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY) + and ( + not isinstance(msg.get("content"), list) + or isinstance(override, list) + ) ): msg["content"] = override if timestamp is not None: diff --git a/tests/agent/test_api_content_sidecar.py b/tests/agent/test_api_content_sidecar.py index b78fab9f84f2..68bc6bf15e2f 100644 --- a/tests/agent/test_api_content_sidecar.py +++ b/tests/agent/test_api_content_sidecar.py @@ -776,6 +776,67 @@ class TestFlushCompressedSummaryOverrideGuard: finally: db.close() + def test_live_override_skipped_for_compression_merged_row(self, tmp_path): + """Same invariant as the test above, for the in-memory path. + + ``finalize_turn`` calls ``_apply_persist_user_message_override`` and + only then ``_persist_session``, so the live dict is rewritten BEFORE + the DB-write guard above ever sees it. A compaction-merged row must + survive: the summary is the whole pre-compaction history, and this + list is the continuation history the next turn is built from. + """ + from agent.context_compressor import COMPRESSED_SUMMARY_METADATA_KEY + + db = SessionDB(db_path=tmp_path / "state.db") + sid = "sess-merged-live" + db.create_session(session_id=sid, source="cli") + try: + agent = self._make_agent(db, sid) + merged = "[prior context]\ncompaction summary\n\nactual question" + messages = [ + { + "role": "user", + "content": merged, + COMPRESSED_SUMMARY_METADATA_KEY: True, + } + ] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = "actual question" + agent._persist_user_message_timestamp = 1730000000 + + agent._apply_persist_user_message_override(messages) + + assert messages[0]["content"] == merged, ( + "the compaction summary was erased from the continuation history" + ) + # The paired timestamp override is unrelated and still applies. + assert messages[0]["timestamp"] == 1730000000 + finally: + db.close() + + def test_live_override_still_applies_without_merge_marker(self, tmp_path): + """Negative control: an ordinary turn is still cleaned in place.""" + db = SessionDB(db_path=tmp_path / "state.db") + sid = "sess-plain-live" + db.create_session(session_id=sid, source="cli") + try: + agent = self._make_agent(db, sid) + messages = [ + { + "role": "user", + "content": "[gateway note] observed\n\nactual question", + } + ] + agent._persist_user_message_idx = 0 + agent._persist_user_message_override = "actual question" + agent._persist_user_message_timestamp = None + + agent._apply_persist_user_message_override(messages) + + assert messages[0]["content"] == "actual question" + finally: + db.close() + class TestFlushSanitizeDivergenceCapture: def _make_agent(self, db, sid):