diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 6859a28a0..48b97bda7 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -2224,9 +2224,21 @@ This compaction should PRIORITISE preserving all information related to the focu def _find_last_user_message_idx( self, messages: List[Dict[str, Any]], head_end: int ) -> int: - """Return the index of the last user-role message at or after *head_end*, or -1.""" + """Return the index of the last user-role message at or after *head_end*, or -1. + + A context-compaction handoff banner can be inserted as a ``role="user"`` + message (see the summary-role selection in ``compress``). It is internal + continuity state, not a real user turn, so it must not be picked as the + tail anchor — otherwise ``_ensure_last_user_message_in_tail`` protects + the summary and rolls the genuine last user message into the next + compaction, re-triggering the active-task loss the anchor exists to + prevent. + """ for i in range(len(messages) - 1, head_end - 1, -1): - if messages[i].get("role") == "user": + msg = messages[i] + if msg.get("role") == "user" and not self._is_context_summary_content( + msg.get("content") + ): return i return -1 diff --git a/tests/agent/test_compressor_assistant_tail_anchor.py b/tests/agent/test_compressor_assistant_tail_anchor.py index e28bc8213..68d2d9f14 100644 --- a/tests/agent/test_compressor_assistant_tail_anchor.py +++ b/tests/agent/test_compressor_assistant_tail_anchor.py @@ -477,6 +477,51 @@ class TestCompactionRollupReproduction: # --------------------------------------------------------------------------- +class TestFindLastUserMessageIdxSkipsSummaryMarker: + """A context-compaction handoff banner is inserted with ``role="user"`` + when the head ends in an assistant/tool message (see the summary-role + selection in ``compress``). ``_find_last_user_message_idx`` must NOT treat + that banner as the latest user turn — otherwise, on a resumed or + multi-compaction session, ``_ensure_last_user_message_in_tail`` anchors the + tail to the summary and rolls the genuine last user message into the next + compaction, re-triggering the active-task loss the anchor exists to prevent. + (Salvaged from #36626 / issue #36624.) + """ + + def test_skips_user_role_context_summary_marker(self, compressor): + from agent.context_compressor import SUMMARY_PREFIX + + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "REAL current task"}, + {"role": "assistant", "content": "working on it"}, + # A handoff summary re-inserted as a user-role message after resume. + {"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nold"}, + {"role": "assistant", "content": "continuing from the real task"}, + ] + # Latest *real* user message is index 1, not the summary at index 3. + assert compressor._find_last_user_message_idx(messages, head_end=1) == 1 + + def test_returns_real_user_when_no_summary_present(self, compressor): + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "second"}, + ] + assert compressor._find_last_user_message_idx(messages, head_end=1) == 3 + + def test_all_user_messages_are_summaries_returns_minus_one(self, compressor): + from agent.context_compressor import SUMMARY_PREFIX + + messages = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\nhandoff"}, + ] + assert compressor._find_last_user_message_idx(messages, head_end=1) == -1 + + class TestSourceGuardrail: @pytest.fixture def source(self) -> str: