diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 756dd62c4e9d..87ee4631d71d 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2020,9 +2020,12 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args replayed: List[Dict[str, Any]] = [] _relocated_replay_cache_control = None + _dropped_blank_text = False for b in ordered_blocks: clean = _sanitize_replay_block(b) if clean is None: + if isinstance(b, dict) and b.get("type") == "text": + _dropped_blank_text = True if isinstance(b, dict) and isinstance(b.get("cache_control"), dict): # A dropped blank text block can still carry the cache # breakpoint marker -- relocate it rather than losing it. @@ -2036,6 +2039,19 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: if redacted is not None: clean["input"] = redacted replayed.append(clean) + # When every text block was blank and nothing cacheable survived + # (e.g. signed thinking + a blank text block, or a SOLE blank + # cache-marked block), emit the non-whitespace placeholder so the + # replayed message stays schema-valid (#69512) and a relocated cache + # marker still has a carrier instead of being silently lost. + _has_cacheable_replay = any( + isinstance(b, dict) and b.get("type") in {"text", "tool_use"} + for b in replayed + ) + if not _has_cacheable_replay and ( + _dropped_blank_text or _relocated_replay_cache_control is not None + ): + replayed.append({"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}) if replayed: if _relocated_replay_cache_control is not None: _apply_assistant_cache_control_to_last_cacheable_block( diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 4661073277a0..3b42fb21d60f 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -2934,3 +2934,65 @@ class TestAllBlankFallbackAndNonStringText: result = self._convert(msg) # must not raise blocks = result["content"] assert not any(b.get("type") == "text" for b in blocks) + + +class TestReplayAllBlankFallback: + """Regression for the final open review point on #68633 (egilewski): + + ``_relocated_replay_cache_control`` was applied only inside ``if + replayed:``. For ``anthropic_content_blocks`` containing only a blank + cache-marked text block, ``replayed`` became empty, the function fell + through to the main path's ``(empty)`` fallback, and the marker was + lost. A signed-thinking block plus the blank marked text also returned + without any relocated marker (thinking is not a cacheable carrier). + The replay branch now resolves a cacheable ``(empty)`` placeholder when + no cacheable block survives the blank filter. + """ + + def _convert(self, message): + from agent.anthropic_adapter import _convert_assistant_message + return _convert_assistant_message(message) + + def test_sole_blank_marked_replay_block_keeps_marker_on_placeholder(self): + msg = { + "role": "assistant", + "content": "", + "anthropic_content_blocks": [ + {"type": "text", "text": " ", "cache_control": {"type": "ephemeral"}}, + ], + } + result = self._convert(msg) + assert result["content"] == [ + {"type": "text", "text": "(empty)", "cache_control": {"type": "ephemeral"}} + ], result["content"] + + def test_thinking_plus_blank_marked_text_keeps_thinking_and_marker(self): + msg = { + "role": "assistant", + "content": "", + "anthropic_content_blocks": [ + {"type": "thinking", "thinking": "reasoning", "signature": "sig-A"}, + {"type": "text", "text": " ", "cache_control": {"type": "ephemeral"}}, + ], + } + result = self._convert(msg) + blocks = result["content"] + assert blocks[0] == {"type": "thinking", "thinking": "reasoning", "signature": "sig-A"} + marked = [b for b in blocks if isinstance(b.get("cache_control"), dict)] + assert len(marked) == 1 and marked[0]["type"] == "text" + assert marked[0]["text"].strip(), "placeholder must be non-whitespace" + + def test_thinking_plus_blank_unmarked_text_gets_schema_valid_placeholder(self): + """Even without a cache marker, dropping the only text block from a + thinking-only replay must leave schema-valid content.""" + msg = { + "role": "assistant", + "content": "", + "anthropic_content_blocks": [ + {"type": "thinking", "thinking": "reasoning", "signature": "sig-B"}, + {"type": "text", "text": "\n"}, + ], + } + result = self._convert(msg) + texts = [b for b in result["content"] if b.get("type") == "text"] + assert texts == [{"type": "text", "text": "(empty)"}] diff --git a/tests/agent/test_anthropic_whitespace_text_blocks.py b/tests/agent/test_anthropic_whitespace_text_blocks.py index 3ec7366a7463..2b7de1c16222 100644 --- a/tests/agent/test_anthropic_whitespace_text_blocks.py +++ b/tests/agent/test_anthropic_whitespace_text_blocks.py @@ -51,13 +51,20 @@ class TestSafeText: class TestSanitizeReplayBlockWhitespace: - def test_whitespace_text_block_coerced_to_placeholder(self): - out = _sanitize_replay_block({"type": "text", "text": " \n"}) - assert out == {"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER} + def test_whitespace_text_block_dropped(self): + # Blank text blocks are DROPPED at the block level (not coerced in + # place) — the caller relocates any cache_control the block carried + # and appends the non-whitespace placeholder only when nothing + # cacheable survives, so real thinking/tool_use blocks aren't + # cluttered with "(empty)" noise. See _convert_assistant_message. + assert _sanitize_replay_block({"type": "text", "text": " \n"}) is None - def test_empty_text_block_coerced_to_placeholder(self): - out = _sanitize_replay_block({"type": "text", "text": ""}) - assert out == {"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER} + def test_empty_text_block_dropped(self): + assert _sanitize_replay_block({"type": "text", "text": ""}) is None + + def test_none_text_block_dropped_without_crash(self): + # text=None (invalid upstream payload) must not reach .strip(). + assert _sanitize_replay_block({"type": "text", "text": None}) is None def test_real_text_block_unchanged(self): out = _sanitize_replay_block({"type": "text", "text": "hi"})