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.
This commit is contained in:
dsad 2026-07-26 19:10:07 +00:00 committed by Teknium
parent 6290cd0d59
commit 0fb46b8185
2 changed files with 78 additions and 2 deletions

View file

@ -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:

View file

@ -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):