From b8bfd68af137db300951fc4db1161846a712114f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:53:03 +0530 Subject: [PATCH] fix(agent): make micro-compaction alternation-safe and defrag user-preserving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two integration bugs found during review of #74522, both confirmed with empirical probes against the production message-repair path: 1. Alternation: the summary marker was role="user" and an exchange was a single assistant+tools group, so splicing between two user turns produced user -> marker(user) -> user. The pre-request repair_message_sequence pass (conversation_loop.py, runs before EVERY API call) then merged the marker into the neighbouring real user message: metadata gone, cursor unrecoverable on resume, and the summary text duplicated into the transcript on every later pass (the transcript GREW every turn). Fix: an exchange is now a full agent turn (assistant + tools + follow-up assistant iterations, bounded by user messages), the marker is assistant-role, and superseding an old marker deliberately merges the two adjacent real user turns (plain-text \n\n-join, identical to repair pass 2) so the returned transcript is alternation-valid by construction. Probe result: repairs 0 (was 2), marker survives, no summary leakage. 2. Defrag destroyed user messages: _defrag_rolling_summary serialized the whole remaining middle (user turns included) and spliced it away — 8 of 10 user prompts destroyed in one pass, contradicting the feature's "your messages are never compacted" invariant. Fix: defrag now re-summarizes only the rolling summary TEXT and rewrites the marker content in place; transcript shape, cursor, and user turns untouched. Probe result: 10 of 10 user prompts survive. Also: marker provenance is now COMPRESSED_SUMMARY_HAS_USER_TURN_KEY=False — micro markers absorb only assistant/tool content (#64650 invariant), and real user turns remain in the transcript for provenance detection. Adds 5 regression tests (repair-pass integration, alternation on multi-iteration tool turns, defrag user survival, defrag input scope, marker provenance); updates the two existing tests and the design doc to the corrected semantics. 28 tests pass. --- agent/context_compressor.py | 242 ++++++++++++++++++++------- docs/micro-compaction.md | 24 ++- tests/agent/test_micro_compaction.py | 160 +++++++++++++++++- 3 files changed, 349 insertions(+), 77 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index f8ff0b861b8..7ff6f92bf59 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -5069,36 +5069,46 @@ This compaction should PRIORITISE preserving all information related to the focu ) -> Optional[tuple[int, int]]: """Find the next complete exchange starting at *start*. - An exchange is an assistant message plus its tool results. Returns - ``(exchange_start, exchange_end)`` indices into *messages*, or ``None`` - if no complete exchange is available before *tail_start*. + An exchange is one full agent turn: the first assistant message after + *start* plus everything through the end of that turn — tool results + and any follow-up assistant iterations — up to (exclusive) the next + ``user`` message. Returns ``(exchange_start, exchange_end)`` indices + into *messages*, or ``None`` if no complete, safely-spliceable turn is + available before *tail_start*. - Tool results are consumed as a group following the assistant message - (consecutive ``tool``-role messages). The assistant message itself must - exist; without one there is nothing to summarise. + The full-turn shape is an alternation-safety requirement, not a + convenience: the splice replaces the span with a single + ``assistant``-role summary marker, so the span must be bounded by + user messages on the right (``messages[exchange_end]`` is ``user``). + Absorbing only the first assistant+tools group of a multi-iteration + turn would leave the marker adjacent to the turn's next assistant + message — two consecutive assistant turns, which strict providers + reject and ``repair_message_sequence`` would then mangle. User messages are deliberately NOT part of an exchange. The walk skips past them to reach the assistant message, and ``exchange_start`` is that assistant index, so user turns are never absorbed into the rolling - summary and stay verbatim for the life of the session. This is the - intended behaviour, not an oversight: what the assistant emits is - largely an account of what it did, which survives summarising, while the - user's own words are the instructions everything else is derived from - and are the one thing that cannot be reconstructed from context. They - are also cheap — a prompt is normally a tiny fraction of the tokens a - single tool result costs. + summary and their text stays verbatim for the life of the session. + This is the intended behaviour, not an oversight: what the assistant + emits is largely an account of what it did, which survives summarising, + while the user's own words are the instructions everything else is + derived from and are the one thing that cannot be reconstructed from + context. They are also cheap — a prompt is normally a tiny fraction + of the tokens a single tool result costs. """ idx = start n = len(messages) if idx >= n or idx >= tail_start: return None - # Walk past any user messages or summary markers until we hit an - # assistant message with actual output (content or tool_calls). + # Walk past user messages and existing summary markers until we hit a + # real assistant message with actual output (content or tool_calls). + # Summary markers are assistant-role themselves, so they must be + # skipped explicitly or a rehydrated cursor could try to absorb the + # marker that carries the compacted history. while idx < tail_start and idx < n: msg = messages[idx] - role = msg.get("role") - if role == "assistant": + if msg.get("role") == "assistant" and not self._is_context_summary_message(msg): break idx += 1 @@ -5107,18 +5117,33 @@ This compaction should PRIORITISE preserving all information related to the focu exchange_start = idx - # Advance past the assistant message + # Consume the full turn: assistant / tool messages until the next + # user message (or an existing summary marker) ends the turn. idx += 1 - - # Consume following tool results as part of the same exchange while idx < tail_start and idx < n: - if messages[idx].get("role") == "tool": - idx += 1 - else: + msg = messages[idx] + role = msg.get("role") + if role not in ("assistant", "tool"): break + if self._is_context_summary_message(msg): + break + idx += 1 if idx <= exchange_start: return None + + # Splice-boundary guard: the message right after the exchange must be + # a user turn (or a summary marker, which stands in for one + # structurally). If the walk stopped because it ran into *tail_start* + # mid-turn, splicing here would leave the assistant-role marker + # adjacent to the turn's remaining assistant/tool messages — invalid + # alternation. Skip this pass; the tail recedes as the conversation + # grows and the turn becomes absorbable later. + if idx >= n: + return None + boundary = messages[idx] + if not isinstance(boundary, dict) or boundary.get("role") != "user": + return None return (exchange_start, idx) def _serialize_one_exchange( @@ -5294,25 +5319,51 @@ This compaction should PRIORITISE preserving all information related to the focu def _defrag_rolling_summary( self, messages: List[Dict[str, Any]], - head_end: int, - tail_start: int, - ) -> None: - """Re-summarize the rolling summary + remaining middle in one shot. + ) -> bool: + """Re-summarize the rolling summary TEXT and rewrite the marker in place. - This is a lightweight batch compaction on just the summary and the - remaining un-compacted region \u2014 NOT the full transcript. It replaces - the rolling summary with a fresh, compact version and advances the - cursor to *tail_start*. + Merging exchange after exchange makes the rolling summary baggy — + repetitive, and larger than the material justifies. Defrag compacts + the summary *itself*: one aux call over the accumulated summary text, + then the existing marker's content is rewritten in place. + + Deliberately transcript-shape-neutral: no messages are spliced, no + user turns are touched, and the cursor does not move. The original + implementation serialized the whole remaining middle (user turns + included) and spliced it into the marker, which silently absorbed + user messages — violating the feature's core "your messages are never + compacted" invariant. Un-absorbed exchanges stay where they are and + get absorbed by later per-exchange passes. + + Returns True when a pass actually rewrote the summary. """ - middle_content = self._serialize_one_exchange(messages, head_end, tail_start) - fresh_summary = self._micro_summarize_one(middle_content) - if fresh_summary: - self._micro_compact_rolling_summary = fresh_summary - self._micro_compact_cursor = tail_start - logger.info( - "Micro-compaction defrag: rolling summary re-summarized " - "(%d chars)", len(fresh_summary), - ) + old_summary = self._micro_compact_rolling_summary + if not old_summary.strip(): + return False + # Feed the old summary through the merge prompt with an empty base: + # "merge these decisions into (no previous summary)" is exactly a + # rewrite-compactly instruction for the accumulated text. + self._micro_compact_rolling_summary = "" + fresh_summary = self._micro_summarize_one(old_summary) + if not fresh_summary: + self._micro_compact_rolling_summary = old_summary + return False + self._micro_compact_rolling_summary = fresh_summary + # Rewrite the newest marker's content in place so the transcript and + # the in-memory summary stay in step (resume rehydrates from it). + for idx in range(len(messages) - 1, -1, -1): + entry = messages[idx] + if isinstance(entry, dict) and entry.get(COMPRESSED_SUMMARY_METADATA_KEY): + entry["content"] = self._render_micro_marker_content(fresh_summary) + # Content changed after a possible flush — clear the persisted + # stamp so the DB sync/flush rewrites the row. + entry.pop(_DB_PERSISTED_MARKER, None) + break + logger.info( + "Micro-compaction defrag: rolling summary re-summarized " + "(%d -> %d chars)", len(old_summary), len(fresh_summary), + ) + return True def _micro_compact( self, @@ -5379,23 +5430,26 @@ This compaction should PRIORITISE preserving all information related to the focu def _elapsed_ms() -> int: return int((time.monotonic() - _started_at) * 1000) - # Check for defrag trigger + # Check for defrag trigger: the rolling summary itself has grown + # baggy. Defrag rewrites the summary text and the existing marker in + # place — no splice, no cursor movement, no user turns touched — so + # the transcript shape is unchanged and this pass does not also + # absorb an exchange (one aux call per turn either way). if self._needs_defrag(): - self._defrag_rolling_summary(messages, exchange_start, compress_end) - result = self._splice_micro_compact_result(messages, exchange_start, compress_end) - self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1) - self._sync_micro_compact_to_db(result) - self._micro_compact_consecutive_failures = 0 - self._micro_compact_last_failure_cursor = -1 + defragged = self._defrag_rolling_summary(messages) + if defragged: + self._sync_micro_compact_to_db(messages) + self._micro_compact_consecutive_failures = 0 + self._micro_compact_last_failure_cursor = -1 self._emit_micro_compaction_telemetry( - outcome="defrag", + outcome="defrag" if defragged else "defrag_failed", messages_before=_messages_before, - messages_after=len(result), + messages_after=len(messages), tokens_before=_tokens_before, - tokens_after=estimate_messages_tokens_rough(result), + tokens_after=estimate_messages_tokens_rough(messages), duration_ms=_elapsed_ms(), ) - return result + return messages # Whether this pass's summary will be cumulative — i.e. whether it # subsumes any earlier marker. Captured before summarizing. @@ -5621,23 +5675,36 @@ This compaction should PRIORITISE preserving all information related to the focu ``_compressed_summary`` metadata flag so downstream consumers (resume, handoff, /compress) handle it identically to batch compaction summaries. + + Alternation safety: the marker is ``assistant``-role. An exchange is + a full agent turn bounded by user messages on both sides (see + ``_find_one_exchange``), so the spliced result is + ``user → marker(assistant) → user`` — valid alternation that the + pre-request ``repair_message_sequence`` pass leaves untouched. A + ``user``-role marker in that position produced ``user → user → user``, + and repair then merged the marker into the neighbouring real user + message: metadata gone, cursor unrecoverable, and the summary text + duplicated into the transcript on every subsequent pass. + + Superseding an earlier marker removes the assistant turn that stood + between two real user messages, leaving them adjacent. Those two are + merged (plain-text only, ``\\n\\n``-joined — the same repair pass 2 + would apply) so the transcript is alternation-valid as returned + rather than relying on downstream repair to fix it up. """ summary_text = self._micro_compact_rolling_summary if not summary_text.strip(): return messages - content = ( - f"{SUMMARY_PREFIX}\n\n" - f"{HISTORICAL_TASK_HEADING}\n" - ) - content += summary_text.strip() - content += f"\n\n{_SUMMARY_END_MARKER}" - summary_msg = { - "role": "user", - "content": content, + "role": "assistant", + "content": self._render_micro_marker_content(summary_text), COMPRESSED_SUMMARY_METADATA_KEY: True, - COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: True, + # Honest provenance (#64650): this marker absorbs only + # assistant/tool content — user turns are never micro-compacted, + # so they remain in the transcript and _transcript_has_real_user_turn + # keeps reporting them directly. + COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: False, } result = messages[:splice_start] + [summary_msg] + messages[splice_end:] @@ -5661,10 +5728,63 @@ This compaction should PRIORITISE preserving all information related to the focu if len(marker_idxs) > 1: superseded = set(marker_idxs[:-1]) result = [m for i, m in enumerate(result) if i not in superseded] + result = self._merge_adjacent_user_turns(result) _strip_persistence_markers(result) return result + @staticmethod + def _render_micro_marker_content(summary_text: str) -> str: + """Assemble the marker content wrapper around *summary_text*.""" + return ( + f"{SUMMARY_PREFIX}\n\n" + f"{HISTORICAL_TASK_HEADING}\n" + f"{summary_text.strip()}" + f"\n\n{_SUMMARY_END_MARKER}" + ) + + @staticmethod + def _merge_adjacent_user_turns( + result: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Merge consecutive plain-text real user turns left by a supersede. + + Dropping a superseded marker removes the assistant turn that separated + two real user messages. Merging them here (``\\n\\n``-joined, exactly + what ``repair_message_sequence`` pass 2 does) keeps every byte the + user typed while restoring alternation deliberately, so the marker + and cursor state are never collateral damage of the downstream repair. + Multimodal (list) content is left alone, mirroring the repair pass. + """ + from agent.turn_context import drop_stale_api_content + + merged: List[Dict[str, Any]] = [] + for msg in result: + prev = merged[-1] if merged else None + if ( + isinstance(msg, dict) + and isinstance(prev, dict) + and msg.get("role") == "user" + and prev.get("role") == "user" + and not msg.get(COMPRESSED_SUMMARY_METADATA_KEY) + and not prev.get(COMPRESSED_SUMMARY_METADATA_KEY) + and isinstance(prev.get("content"), str) + and isinstance(msg.get("content"), str) + ): + prev_content = prev["content"] + new_content = msg["content"] + prev["content"] = ( + (prev_content + "\n\n" + new_content) + if prev_content and new_content + else (prev_content or new_content) + ) + # Merged content invalidates the api_content sidecar (exact + # bytes previously sent for the pre-merge message). + drop_stale_api_content(prev) + continue + merged.append(msg) + return merged + def compress( self, messages: List[Dict[str, Any]], diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index dcb414b14c2..7c4a7176ba2 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -51,10 +51,14 @@ compressor to absorb **one** exchange: One exchange per turn. The per-turn cost stays bounded no matter how long the conversation gets. -An **exchange** is an assistant message together with any tool results that -followed it. In tool-heavy work that's where the bulk of the tokens live — a +An **exchange** is one full agent turn: an assistant message together with its +tool results and any follow-up assistant iterations, up to the next user +message. In tool-heavy work that's where the bulk of the tokens live — a file read or a command's output dwarfs the surrounding prose — which is why -absorbing one exchange at a time is worth doing at all. +absorbing one exchange at a time is worth doing at all. Taking the whole turn +(rather than a single assistant+tools group) also keeps the transcript's role +alternation strictly valid: the summary marker is an assistant-role message, +and a full turn is always bounded by user messages on both sides. ## Your messages are never compacted @@ -123,12 +127,14 @@ transcript grows on every turn instead of shrinking. Merge into a summary often enough and it gets baggy — repetitive, and larger than the material justifies. When the running summary crosses a token threshold -(2000 by default), the next pass **defrags**: it re-summarizes the summary and -whatever middle remains in one shot, replacing it with a fresh compact version -and advancing the cursor to the tail. +(2000 by default), the next pass **defrags**: one auxiliary call re-summarizes +the running summary *itself* into a fresh compact version, and the summary +marker in the transcript is rewritten in place. -This is still much cheaper than full batch compaction. It only ever processes the -summary plus the un-absorbed middle, never the whole transcript. +Defrag never touches the transcript's structure — no messages are absorbed or +spliced, the cursor doesn't move, and user turns are untouched. It processes +only the accumulated summary text, never conversation messages, so the +"your messages are never compacted" guarantee holds through it. ### Staying in step with the session database @@ -152,7 +158,7 @@ untouched and the failure is counted. If the *same* exchange fails three times in a row, the cursor is advanced past it anyway. Without that, one bad exchange would be retried on every single turn forever. Those skipped messages stay in the transcript and get picked up by the -next defrag or batch compaction. +next batch compaction. ## Interaction with batch compaction diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index c175a9b6132..543360be4a1 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -70,8 +70,12 @@ class TestMicroCompaction: markers = _summary_markers(result) assert len(markers) == 1 assert "ROLLING SUMMARY" in markers[0]["content"] - # The marker stands in for a user turn, like batch compaction's does. - assert markers[0]["role"] == "user" + # The marker is assistant-role: an exchange is a full agent turn + # bounded by user messages, so user → marker(assistant) → user keeps + # strict alternation. A user-role marker produced user → user → user, + # and the pre-request repair_message_sequence pass merged the marker + # into the neighbouring real user turn — metadata gone, cursor lost. + assert markers[0]["role"] == "assistant" def test_disabled_is_a_no_op(self): cc = _compressor() @@ -168,12 +172,17 @@ class TestMicroCompaction: assert result[-1] == messages[-1], "most recent turn must be preserved" def test_user_messages_are_never_absorbed(self): - """User turns stay verbatim for the life of the session — by design. + """Every byte the user typed stays in the transcript — by design. Assistant output is largely an account of what was done and survives summarising; the user's own words are the intent everything else is derived from and can't be reconstructed from it. So an exchange starts at the assistant message and the walk skips past user turns. + + The invariant is on user TEXT, not message-list shape: superseding an + old marker leaves two real user turns adjacent, and they are merged + (\\n\\n-joined, same as repair_message_sequence pass 2) to keep strict + alternation. Text is never summarized or dropped. """ cc = _compressor() messages = _conversation(exchanges=10) @@ -182,11 +191,14 @@ class TestMicroCompaction: for _ in range(5): messages = cc._micro_compact(messages) - surviving = [ + surviving_text = "\n\n".join( m["content"] for m in messages if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) - ] - assert surviving == originals, "user turns must survive verbatim" + ) + for original in originals: + assert original in surviving_text, ( + f"user text {original!r} must survive verbatim" + ) def test_cursor_is_derived_from_the_spliced_list(self): """The cursor must never carry over a pre-splice index. @@ -474,9 +486,20 @@ class TestMicroCompaction: assert cc._micro_compact_tokens_saved_total > 0 def test_defrag_triggers_once_the_rolling_summary_grows(self): + """Defrag rewrites the summary text and the marker — nothing else. + + The original implementation spliced the whole remaining middle (user + turns included) into the marker, silently absorbing user messages. + Defrag is now transcript-shape-neutral: same message list, same + cursor, marker content rewritten in place. + """ cc = _compressor(summary="FRESH DEFRAGGED SUMMARY") - cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold messages = _conversation(exchanges=8) + # Seed a real marker + oversized rolling summary, as after many passes. + messages = cc._micro_compact(list(messages)) + cc._micro_compact_rolling_summary = "x" * 40_000 # far over the threshold + cursor_before = cc._micro_compact_cursor + shape_before = [m.get("role") for m in messages] assert cc._needs_defrag() is True result = cc._micro_compact(list(messages)) @@ -485,3 +508,126 @@ class TestMicroCompaction: markers = _summary_markers(result) assert len(markers) == 1 assert "FRESH DEFRAGGED SUMMARY" in markers[0]["content"] + # Shape-neutral: no messages absorbed or spliced, cursor unmoved. + assert [m.get("role") for m in result] == shape_before + assert cc._micro_compact_cursor == cursor_before + + def test_defrag_never_absorbs_user_messages(self): + """Defrag must not touch user turns — the feature's core invariant. + + The original implementation serialized head..tail (user turns + included) and spliced it away: 8 of 10 user prompts were destroyed in + one pass. Defrag now only rewrites the rolling summary text. + """ + cc = _compressor(summary="DEFRAGGED") + messages = [{"role": "system", "content": "sys"}] + for i in range(10): + messages.append({"role": "user", "content": f"UNIQUE-USER-PROMPT-{i}"}) + messages.append({"role": "assistant", "content": f"answer {i} " + "z" * 400}) + + cc._micro_compact_rolling_summary = "x" * 40_000 # force defrag + result = cc._micro_compact(list(messages)) + + surviving = [ + m["content"] for m in result + if m.get("role") == "user" and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) + ] + for i in range(10): + assert any(f"UNIQUE-USER-PROMPT-{i}" in s for s in surviving), ( + f"user prompt {i} was absorbed by defrag" + ) + + def test_defrag_summarizes_only_the_summary_text(self): + """The defrag aux call receives the rolling summary, not the transcript.""" + cc = _compressor() + captured = {} + + def capture(text): + captured["text"] = text + return "DEFRAGGED" + + cc._micro_summarize_one = capture + cc._micro_compact_rolling_summary = "OLD-SUMMARY " + "x" * 40_000 + messages = _conversation(exchanges=8) + cc._micro_compact(list(messages)) + + assert "OLD-SUMMARY" in captured["text"] + assert "[USER]" not in captured["text"], ( + "defrag must never serialize transcript user turns" + ) + + def test_spliced_transcript_survives_repair_message_sequence(self): + """The compacted transcript must survive the production repair pass. + + conversation_loop runs repair_message_sequence before EVERY API call. + With the old user-role marker, splicing next to a real user turn made + user → user → user; repair merged the marker into the real user + message — metadata gone, cursor unrecoverable, summary text duplicated + into the transcript on every later pass. Pin the integration: markers + survive repair untouched, and no summary text leaks into real user + messages. + """ + from agent.agent_runtime_helpers import repair_message_sequence + + class _DummyAgent: + session_id = "probe" + _last_flushed_db_idx = 0 + + cc = _compressor() + messages = _conversation(exchanges=8) + + for _ in range(3): + messages = cc._micro_compact(messages) + repairs = repair_message_sequence(_DummyAgent(), messages) + assert repairs == 0, ( + "micro-compacted transcript must already be alternation-valid" + ) + markers = _summary_markers(messages) + assert len(markers) == 1, "marker destroyed by repair pass" + polluted = [ + m for m in messages + if m.get("role") == "user" + and not m.get(COMPRESSED_SUMMARY_METADATA_KEY) + and "ROLLING SUMMARY" in str(m.get("content")) + ] + assert not polluted, "summary text leaked into a real user message" + + def test_spliced_transcript_has_no_consecutive_same_role_messages(self): + """Alternation invariant, checked directly on tool-bearing turns.""" + cc = _compressor() + msgs = [{"role": "system", "content": "sys"}] + for i in range(8): + msgs.append({"role": "user", "content": f"q{i}"}) + msgs.append({ + "role": "assistant", + "content": f"a{i}", + "tool_calls": [ + {"id": f"c{i}-{j}", "type": "function", + "function": {"name": "f", "arguments": "{}"}} + for j in range(2) + ], + }) + for j in range(2): + msgs.append({"role": "tool", "tool_call_id": f"c{i}-{j}", + "content": "T" * 400}) + # Multi-iteration turn: a second assistant+tools group before the + # next user message — the splice must absorb the WHOLE turn. + msgs.append({"role": "assistant", "content": f"followup {i} " + "y" * 200}) + + for _ in range(4): + msgs = cc._micro_compact(msgs) + for a, b in zip(msgs, msgs[1:]): + ra, rb = a.get("role"), b.get("role") + assert not (ra == rb and ra in ("user", "assistant")), ( + f"consecutive {ra} messages after micro-compaction" + ) + + def test_marker_reports_no_user_provenance(self): + """Micro markers absorb only assistant/tool content (#64650).""" + from agent.context_compressor import COMPRESSED_SUMMARY_HAS_USER_TURN_KEY + + cc = _compressor() + result = cc._micro_compact(_conversation(exchanges=6)) + marker = _summary_markers(result)[0] + + assert marker[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False