{"repo":"NousResearch/hermes-agent","public_id":"42768","display_label":"Compaction summary role and leak handling in chat context","summary":"Most members concern context compaction summaries being inserted with role=\"assistant\" and appearing in chat, with several pull requests proposing role=\"user\" instead. Two members are adjacent rather than identical: one adds an end-of-summary marker to standalone summaries, and one batches several compressor changes (orphan tool_calls, cooldown abort flags, system-head summary role).","subsystem":"agent","counts":{"members":7,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":0,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56197,"merged_at":"2026-07-01T08:54:42+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-01T08:54:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":33256,"pr_number":45153,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-12T22:05:01+00:00"},{"issue_number":42768,"pr_number":45153,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-12T22:05:01+00:00"},{"issue_number":42768,"pr_number":56197,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-01T08:54:42+00:00"},{"issue_number":51218,"pr_number":56197,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-01T08:54:42+00:00"}],"members":[{"kind":"issue","number":42768,"title":"[Bug]: Context compaction summaries leak into chat as assistant responses when inserted with role=\"assistant\"","url":null,"state":"open"},{"kind":"pr","number":42783,"title":"Fix memory compaction leak when role=assistant","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42804,"title":"fix(agent): always use role=\"user\" for compaction summaries to prevent loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42783","number":42783}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":42830,"title":"fix(agent): keep compaction summaries as user role (#42768)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45153","number":45153},{"kind":"pr","value":"#52160","number":52160},{"kind":"pr","value":"#56197","number":56197}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42897,"title":"fix(compression): always use role=user for compaction summaries (#42768)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42830","number":42830},{"kind":"pr","value":"#42768","number":42768}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45153,"title":"fix(compression): append END OF CONTEXT SUMMARY marker to all standalone summaries (salvages #33346)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -69,6 +69,16 @@\n )\n LEGACY_SUMMARY_PREFIX = \"[CONTEXT SUMMARY]:\"\n \n+# Appended to every standalone summary message (and to the merged-into-tail\n+# prefix) so the model has an unambiguous \"summary ends here\" boundary.\n+# Without it, weak models read the verbatim \"## Active Task\" quote as fresh\n+# user input (#11475, #14521) or regurgitate an assistant-role summary as\n+# their own output (#33256).\n+_SUMMARY_END_MARKER = (\n+    \"--- END OF CONTEXT SUMMARY — \"\n+    \"respond to the message below, not the summary above ---\"\n+)\n+\n # Handoff prefixes that shipped in earlier releases. A summary persisted under\n # one of these can be inherited into a resumed lineage (#35344); when it is\n # re-normalized on re-compaction we must strip the OLD prefix too, otherwise the","@@ -1616,7 +1626,13 @@ def _strip_summary_prefix(summary: str) -> str:\n         text = (summary or \"\").strip()\n         for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):\n             if text.startswith(prefix):\n-                return text[len(prefix):].lstrip()\n+                text = text[len(prefix):].lstrip()\n+                break\n+        # Strip the trailing end marker too — a rehydrated handoff body that\n+        # keeps it would leak the boundary directive into the iterative-update\n+        # summarizer prompt (and the marker is re-appended on insertion anyway).\n+        if text.endswith(_SUMMARY_END_MARKER):\n+            text = text[: -len(_SUMMARY_END_MARKER)].rstrip()\n         return text\n \n     @classmethod","@@ -2193,15 +2209,13 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n \n         # When the summary lands as a standalone role=\"user\" message,\n         # weak models read the verbatim \"## Active Task\" quote of a past\n-        # user request as fresh input (#11475, #14521). Append the explicit\n-        # end marker — the same one used in the merge-into-tail path — so\n-        # the model has a clear \"summary above, not new input\" signal.\n-        if not _merge_summary_into_tail and summary_role == \"user\":\n-            summary = (\n-                summary\n-                + \"\\n\\n--- END OF CONTEXT SUMMARY — \"\n-                \"respond to the message below, not the summary above ---\"\n-            )\n+        # user request as fresh input (#11475, #14521).\n+        # When it lands as role=\"assistant\", models may regurgitate the\n+        # summary text as their own output (#33256). In both cases, append\n+        # the explicit end marker so the model has a clear \"summary ends\n+        # here, respond to the message below\" signal.\n+        if not _merge_summary_into_tail:\n+            summary = summary + \"\\n\\n\" + _SUMMARY_END_MARKER\n \n         if not _merge_summary_into_tail:\n             compressed.append({\"role\": summary_role, \"content\": summary})","@@ -2209,11 +2223,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         for i in range(compress_end, n_messages):\n             msg = messages[i].copy()\n             if _merge_summary_into_tail and i == compress_end:\n-                merged_prefix = (\n-                    summary\n-                    + \"\\n\\n--- END OF CONTEXT SUMMARY — \"\n-                    \"respond to the message below, not the summary above ---\\n\\n\"\n-                )\n+                merged_prefix = summary + \"\\n\\n\" + _SUMMARY_END_MARKER + \"\\n\\n\"\n                 msg[\"content\"] = _append_text_to_content(\n                     msg.get(\"content\"),\n                     merged_prefix,"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -1252,6 +1252,48 @@ def test_user_role_summary_carries_end_marker(self):\n             \"respond to the message below, not the summary above ---\"\n         )\n \n+    def test_assistant_role_summary_carries_end_marker(self):\n+        \"\"\"When the summary lands as standalone role='assistant' (head ends\n+        with user), the message body must include the explicit\n+        '--- END OF CONTEXT SUMMARY ---' marker. Without it, models may\n+        regurgitate the summary text as their own output (#33256).\n+        \"\"\"\n+        mock_client = MagicMock()\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"[CONTEXT SUMMARY]: stuff happened\"\n+        mock_client.chat.completions.create.return_value = mock_response\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(model=\"test\", quiet_mode=True, protect_first_n=2, protect_last_n=2)\n+\n+        # head_last=user → summary_role=\"assistant\" (same setup as\n+        # test_summary_role_avoids_consecutive_user_when_head_ends_with_user).\n+        # With min_tail=3, tail = last 3 messages (indices 5-7).\n+        # head_last=user, tail_first=user → the assistant-role summary does\n+        # not collide with either neighbor and should be inserted standalone.\n+        msgs = [\n+            {\"role\": \"system\", \"content\": \"system prompt\"},\n+            {\"role\": \"user\", \"content\": \"msg 1\"},\n+            {\"role\": \"user\", \"content\": \"msg 2\"},  # last head — user\n+            {\"role\": \"assistant\", \"content\": \"msg 3\"},\n+            {\"role\": \"user\", \"content\": \"msg 4\"},\n+            {\"role\": \"user\", \"content\": \"msg 5\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 6\"},\n+            {\"role\": \"user\", \"content\": \"msg 7\"},\n+        ]\n+        with patch(\"agent.context_compressor.call_llm\", return_value=mock_response):\n+            result = c.compress(msgs)\n+\n+        summary_msg = next(\n+            m for m in result if (m.get(\"content\") or \"\").startswith(SUMMARY_PREFIX)\n+        )\n+        assert summary_msg[\"role\"] == \"assistant\"\n+        assert \"END OF CONTEXT SUMMARY\" in summary_msg[\"content\"]\n+        assert summary_msg[\"content\"].rstrip().endswith(\n+            \"respond to the message below, not the summary above ---\"\n+        )\n+\n     def test_summary_role_avoids_consecutive_user_messages(self):\n         \"\"\"Summary role should alternate with the last head message to avoid consecutive same-role messages.\"\"\"\n         mock_client = MagicMock()\n"]}]},{"kind":"pr","number":56197,"title":"fix(compressor): batch salvage \\u2014 orphan tool_calls, cooldown abort flags, system-head summary role (#51225 #52056 #52167)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -2092,8 +2092,16 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,\n            The API rejects this because every tool_call must be followed by\n            a tool result with the matching call_id.\n \n-        This method removes orphaned results and inserts stub results for\n-        orphaned calls so the message list is always well-formed.\n+        This method removes orphaned results and strips orphaned tool_calls\n+        from assistant messages so the message list is always well-formed.\n+\n+        Previous approach inserted stub ``role=\"tool\"`` results for orphaned\n+        tool_calls.  That caused a secondary failure: the pre-API\n+        ``repair_message_sequence()`` uses ``tc.get(\"id\")`` to track known\n+        call IDs while this sanitizer uses ``call_id || id``.  When the two\n+        disagree (Codex Responses API format: ``id != call_id``), stubs get\n+        silently dropped by the repair pass, re-exposing the original orphans.\n+        Stripping at the source avoids this entire class of mismatch.\n         \"\"\"\n         surviving_call_ids: set = set()\n         for msg in messages:","@@ -2120,24 +2128,34 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,\n             if not self.quiet_mode:\n                 logger.info(\"Compression sanitizer: removed %d orphaned tool result(s)\", len(orphaned_results))\n \n-        # 2. Add stub results for assistant tool_calls whose results were dropped\n+        # 2. Strip orphaned tool_calls from assistant messages whose results\n+        #    were dropped.  Stripping is preferred over inserting stub results\n+        #    because stubs can be dropped by downstream repair_message_sequence\n+        #    when call_id != id (Codex Responses API format), re-exposing orphans.\n         missing_results = surviving_call_ids - result_call_ids\n         if missing_results:\n-            patched: List[Dict[str, Any]] = []\n             for msg in messages:\n-                patched.append(msg)\n-                if msg.get(\"role\") == \"assistant\":\n-                    for tc in msg.get(\"tool_calls\") or []:\n-                        cid = self._get_tool_call_id(tc)\n-                        if cid in missing_results:\n-                            patched.append({\n-                                \"role\": \"tool\",\n-                                \"content\": \"[Result from earlier conversation — see context summary above]\",\n-                                \"tool_call_id\": cid,\n-                            })\n-            messages = patched\n+                if msg.get(\"role\") != \"assistant\":\n+                    continue\n+                tcs = msg.get(\"tool_calls\")\n+                if not tcs:\n+                    continue\n+                kept = [tc for tc in tcs if self._get_tool_call_id(tc) not in missing_results]\n+                if len(kept) != len(tcs):\n+                    if kept:\n+                        msg[\"tool_calls\"] = kept\n+                    else:\n+                        msg.pop(\"tool_calls\", None)\n+                        # Ensure the assistant message still has visible\n+                        # content so the API does not reject an empty turn.\n+                        content = msg.get(\"content\")\n+                        if not content or (isinstance(content, str) and not content.strip()):\n+                            msg[\"content\"] = \"(tool call removed)\"\n             if not self.quiet_mode:\n-                logger.info(\"Compression sanitizer: added %d stub tool result(s)\", len(missing_results))\n+                logger.info(\n+                    \"Compression sanitizer: stripped %d orphaned tool_call(s) from assistant messages\",\n+                    len(missing_results),\n+                )\n \n         return messages\n ","@@ -2595,8 +2613,16 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         self._last_aux_model_failure_error = None\n         self._last_aux_model_failure_model = None\n         self._last_compress_aborted = False\n-        self._last_summary_auth_failure = False\n-        self._last_summary_network_failure = False\n+        # NOTE: do NOT reset _last_summary_auth_failure or\n+        # _last_summary_network_failure here.  These flags are set by\n+        # _generate_summary() on a terminal failure and are already cleared on\n+        # a successful summary.  Resetting them eagerly defeats the cooldown\n+        # protection: _generate_summary() returns None from the cooldown\n+        # early-return without re-asserting these flags, so the abort guard\n+        # below would see False and fall through to the destructive\n+        # static-fallback — the exact data-loss #29559 describes.  Letting them\n+        # persist across compress() calls is safe because a successful summary\n+        # always clears both.\n \n         # Manual /compress (force=True) bypasses the failure cooldown so the\n         # user can retry immediately after an auto-compress abort.  Without","@@ -2792,9 +2818,17 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         _merge_summary_into_tail = False\n         last_head_role = messages[compress_start - 1].get(\"role\", \"user\") if compress_start > 0 else \"user\"\n         first_tail_role = messages[compress_end].get(\"role\", \"user\") if compress_end < n_messages else \"user\"\n+        # When the only protected head message is the system prompt, the\n+        # summary becomes the first *visible* message in the API request\n+        # (most adapters — Anthropic, Bedrock — send the system prompt as\n+        # a separate ``system`` parameter, not inside ``messages[]``).\n+        # Anthropic unconditionally rejects requests whose first message\n+        # is not role=user, so we must pin the summary to \"user\" and\n+        # prevent the flip logic below from reverting it (#52160).\n+        _force_user_leading = last_head_role == \"system\"\n         # Pick a role that avoids consecutive same-role with both neighbors.\n         # Priority: avoid colliding with head (already committed), then tail.\n-        if last_head_role in {\"assistant\", \"tool\"}:\n+        if last_head_role in {\"assistant\", \"tool\"} or _force_user_leading:\n             summary_role = \"user\"\n         else:\n             summary_role = \"assistant\"","@@ -2802,7 +2836,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         # collide with the head, flip it.\n         if summary_role == first_tail_role:\n             flipped = \"assistant\" if summary_role == \"user\" else \"user\"\n-            if flipped != last_head_role:\n+            if flipped != last_head_role and not _force_user_leading:\n                 summary_role = flipped\n             else:\n                 # Both roles would create consecutive same-role messages"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -2919,3 +2919,284 @@ def test_no_orphan_after_full_compaction_cycle(self, compressor):\n                 f\"Orphan user turn at tail start: {tail[0]['content']!r} — \"\n                 f\"next role is {tail[1].get('role') if len(tail) > 1 else 'nothing'}\"\n             )\n+\n+\n+class TestSanitizerStripsOrphanedToolCalls:\n+    \"\"\"PR #51218 (salvaged from #51225): orphaned tool_calls are stripped from\n+    assistant messages instead of having stub tool results inserted, avoiding\n+    the call_id != id mismatch that let downstream repair_message_sequence drop\n+    the stubs and re-expose orphans.\"\"\"\n+\n+    def test_sanitizer_strips_orphaned_tool_calls(self, compressor):\n+        \"\"\"Orphaned tool_calls (no matching tool result) are stripped from\n+        assistant messages instead of having stubs inserted.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\"id\": \"tc_orphan\", \"function\": {\"name\": \"search\", \"arguments\": \"{}\"}},\n+                ],\n+            },\n+            {\"role\": \"user\", \"content\": \"never mind\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        # Orphaned tool_call should be stripped, not stub-inserted\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert not asst.get(\"tool_calls\"), \"orphaned tool_calls should be stripped\"\n+        # No stub tool messages should be added\n+        assert not any(m.get(\"role\") == \"tool\" for m in sanitized)\n+        # Empty assistant should get placeholder content\n+        assert asst.get(\"content\") == \"(tool call removed)\"\n+\n+    def test_sanitizer_strips_orphaned_keeps_valid(self, compressor):\n+        \"\"\"When an assistant has both valid and orphaned tool_calls, only\n+        the orphans are stripped.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\"id\": \"tc_valid\", \"function\": {\"name\": \"read_file\", \"arguments\": \"{}\"}},\n+                    {\"id\": \"tc_orphan\", \"function\": {\"name\": \"search\", \"arguments\": \"{}\"}},\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"tc_valid\", \"content\": \"file content\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert len(asst[\"tool_calls\"]) == 1\n+        assert asst[\"tool_calls\"][0][\"id\"] == \"tc_valid\"\n+        # Valid tool result preserved\n+        tool_msgs = [m for m in sanitized if m.get(\"role\") == \"tool\"]\n+        assert len(tool_msgs) == 1\n+        assert tool_msgs[0][\"tool_call_id\"] == \"tc_valid\"\n+\n+    def test_sanitizer_strips_orphaned_preserves_text_content(self, compressor):\n+        \"\"\"When an assistant has text content AND orphaned tool_calls,\n+        the text is preserved and only tool_calls are stripped.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"Let me search for that.\",\n+                \"tool_calls\": [\n+                    {\"id\": \"tc_orphan\", \"function\": {\"name\": \"search\", \"arguments\": \"{}\"}},\n+                ],\n+            },\n+            {\"role\": \"user\", \"content\": \"thanks\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert asst[\"content\"] == \"Let me search for that.\"\n+        assert not asst.get(\"tool_calls\")\n+        # The placeholder must NOT overwrite existing text content.\n+        assert asst[\"content\"] != \"(tool call removed)\"\n+\n+    def test_sanitizer_strips_orphaned_with_call_id_mismatch(self, compressor):\n+        \"\"\"Stubs with call_id != id used to be dropped by downstream\n+        repair_message_sequence, re-exposing orphans.  Stripping avoids\n+        this entirely.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"fc_abc\",\n+                        \"call_id\": \"call_abc\",\n+                        \"function\": {\"name\": \"search\", \"arguments\": \"{}\"},\n+                    },\n+                ],\n+            },\n+            # No tool result for call_abc — orphaned\n+            {\"role\": \"user\", \"content\": \"next\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert not asst.get(\"tool_calls\")\n+        # No stub tool messages (which would have call_id != id mismatch)\n+\n+\n+class TestCooldownReentryAbort:\n+    \"\"\"Regression: a second compress() call during the failure cooldown must\n+    still abort when the original failure was a network/auth error.\n+\n+    Before the fix, compress() unconditionally reset _last_summary_network_failure\n+    and _last_summary_auth_failure at the top of every call.  When\n+    _generate_summary() returned None from the cooldown early-return (without\n+    re-setting the flags), the abort guard saw False and fell through to the\n+    destructive static-fallback path — reproducing the data-loss scenario from\n+    #29559 / #25585 that PR #51881 originally fixed.\n+    \"\"\"\n+\n+    def _msgs(self, n=12):\n+        return [\n+            {\"role\": \"user\" if i % 2 == 0 else \"assistant\", \"content\": f\"msg {i}\"}\n+            for i in range(n)\n+        ]\n+\n+    def test_network_failure_cooldown_reentry_still_aborts(self):\n+        \"\"\"ConnectionError → first compress aborts (PR #51881).  Second\n+        compress within the 30s cooldown must ALSO abort — not drop the\n+        middle window via the static-fallback path.\"\"\"\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\",\n+                quiet_mode=True,\n+                protect_first_n=2,\n+                protect_last_n=2,\n+                abort_on_summary_failure=False,\n+            )\n+        msgs = self._msgs(12)\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=ConnectionError(\"Connection error.\"),\n+        ):\n+            first = c.compress(msgs, current_tokens=[redacted:secret-assignment] force=True)\n+        assert first == msgs\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_network_failure is True\n+\n+        second = c.compress(msgs, current_tokens=[redacted:secret-assignment]\n+        assert second == msgs, (\n+            \"Second compress during cooldown must abort (preserve messages), \"\n+            \"not drop the middle window via static-fallback\"\n+        )\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_fallback_used is False\n+\n+    def test_auth_failure_cooldown_reentry_still_aborts(self):\n+        \"\"\"Same re-entry hole for auth failures: a 401 sets the flag, cooldown\n+        returns None, second compress must still abort.\"\"\"\n+        err = Exception(\"Error code: 401 - invalid api key\")\n+        err.status_code = 401\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\",\n+                quiet_mode=True,\n+                protect_first_n=2,\n+                protect_last_n=2,\n+                abort_on_summary_failure=False,\n+            )\n+        msgs = self._msgs(12)\n+\n+        with patch(\"agent.context_compressor.call_llm\", side_effect=err):\n+            first = c.compress(msgs, current_tokens=[redacted:secret-assignment] force=True)\n+        assert first == msgs\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_auth_failure is True\n+\n+        second = c.compress(msgs, current_tokens=[redacted:secret-assignment]\n+        assert second == msgs, (\n+            \"Second compress during cooldown must abort (preserve messages), \"\n+            \"not drop the middle window via static-fallback\"\n+        )\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_fallback_used is False\n+\n+\n+class TestDoubleCompactionSummaryRole:\n+    \"\"\"PR #52160 (salvaged from #52167): when only the system prompt is\n+    protected, the summary must lead with role=user (Anthropic/Bedrock send\n+    system as a separate param, so the summary is the first visible message).\"\"\"\n+\n+    def test_double_compaction_summary_must_be_user_when_only_system_protected(self):\n+        \"\"\"After the first compression, protect_first_n decays to 0.\n+\n+        On the second compression the only protected head message is the\n+        system prompt (role=system).  The summary becomes the first\n+        *visible* message in the API request because adapters like\n+        Anthropic and Bedrock send the system prompt as a separate\n+        ``system`` parameter.  The summary MUST be role=user or the\n+        provider rejects with HTTP 400 (#52160).\n+        \"\"\"\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"summary of earlier turns\"\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\", quiet_mode=True, protect_first_n=2, protect_last_n=2,\n+            )\n+        # Simulate second compression: protect_first_n decays to 0.\n+        c.compression_count = 1\n+\n+        # compress_start will be 1 (system only), last_head_role = \"system\".\n+        # Without the fix, summary_role would be \"assistant\".\n+        msgs = [\n+            {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n+            {\"role\": \"user\", \"content\": \"msg 1\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 2\"},\n+            {\"role\": \"user\", \"content\": \"msg 3\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 4\"},\n+            {\"role\": \"user\", \"content\": \"msg 5\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 6\"},\n+        ]\n+        with patch(\"agent.context_compressor.call_llm\", return_value=mock_response):\n+            result = c.compress(msgs)\n+\n+        # The system message must still be at index 0.\n+        assert result[0][\"role\"] == \"system\"\n+        # The summary (first non-system message) must be role=user.\n+        non_system = [m for m in result if m.get(\"role\") != \"system\"]\n+        assert non_system, \"expected at least one non-system message\"\n+        assert non_system[0][\"role\"] == \"user\", (\n+            f\"first non-system message must be role=user for Anthropic \"\n+            f\"compatibility, got role={non_system[0]['role']!r}\"\n+        )\n+\n+    def test_double_compaction_user_tail_merges_into_tail(self):\n+        \"\"\"When the summary is forced to role=user (system-only head) and\n+        the first tail message is also user, the summary must merge into\n+        the tail rather than flipping back to assistant (#52160).\n+        \"\"\"\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"summary of earlier turns\"\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\", quiet_mode=True, protect_first_n=2, protect_last_n=2,\n+            )\n+        c.compression_count = 1  # decay protect_first_n\n+\n+        # tail starts with user → would collide with forced summary_role=user.\n+        # The fix should merge into tail instead of flipping to assistant.\n+        msgs = [\n+            {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n+            {\"role\": \"user\", \"content\": \"msg 1\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 2\"},\n+            {\"role\": \"user\", \"content\": \"msg 3\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 4\"},\n+            {\"role\": \"user\", \"content\": \"msg 5\"},       # tail start (user)\n+            {\"role\": \"assistant\", \"content\": \"msg 6\"},\n+            {\"role\": \"user\", \"content\": \"msg 7\"},\n+        ]\n+        with patch(\"agent.context_compressor.call_llm\", return_value=mock_response):\n+            result = c.compress(msgs)\n+\n+        # No standalone summary message should exist (merged into tail).\n+        summary_msgs = [\n+            m for m in result\n+            if m.get(\"_compressed_summary\") and \"msg 5\" not in (m.get(\"content\") or \"\")\n+        ]\n+        assert len(summary_msgs) == 0, (\n+            \"summary should be merged into tail, not standalone\"\n+        )\n+        # The first non-system message must be role=user.\n+        non_system = [m for m in result if m.get(\"role\") != \"system\"]\n+        assert non_system[0][\"role\"] == \"user\"\n+        # The merged tail should contain the summary text.\n+        assert any(\n+            \"summary of earlier turns\" in (m.get(\"content\") or \"\")\n+            for m in result\n+        )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}