"""Idempotence law for transcript decoration passes: f(f(x)) == f(x). Pins bug #90971 (fixed in PR #91350): ``apply_anthropic_cache_control`` was non-idempotent on pre-decorated input — the negative-slice / tool-budget interplay stacked a fifth ``cache_control`` marker onto messages a prior pass had already decorated, reproducing Anthropic's ``cache_control can only be specified up to 4 times`` HTTP 400 mid-conversation. Behavior contracts pinned here (complementing, not duplicating, tests/agent/test_prompt_caching.py::TestApplyIdempotency which covers exact layout convergence and caller non-mutation): 1. IDEMPOTENCE — for every realistic transcript shape x, the serialized form of f(f(x)) is byte-identical to f(x). 2. BUDGET — the total decoration count after any number of passes never exceeds Anthropic's 4-breakpoint budget. Shapes include the #90971 regression input (already-decorated transcripts) plus dict-content and list-content messages, tool blocks, and mixed forms. """ import copy import json import pytest from agent.prompt_caching import ( _count_cache_markers, apply_anthropic_cache_control, ) MARKER = {"type": "ephemeral"} BUDGET = 4 # Anthropic's hard cap on cache_control breakpoints def _canon(messages): """Deterministic byte serialization for the byte-identity assertion.""" return json.dumps(messages, sort_keys=True, ensure_ascii=False).encode() def _tool_block(i, decorated=False): # Two text parts: single-part [{"type","text"}] lists are canonicalized # to plain strings by the strip pass (by-design flattening of the shape # decoration itself produces), so multi-part is the realistic list form # that must round-trip byte-exactly. part = {"type": "text", "text": f"tool result {i}"} if decorated: part["cache_control"] = dict(MARKER) return { "role": "tool", "tool_call_id": f"call_{i}", "content": [{"type": "text", "text": f"tool header {i}"}, part], } def _clean_transcript(): """system + alternating user/assistant, plain string (dict) content.""" msgs = [{"role": "system", "content": "STATIC_PREFIX system rules and tools"}] for i in range(6): msgs.append({"role": "user", "content": f"user turn {i}"}) msgs.append({"role": "assistant", "content": f"assistant turn {i}"}) return msgs def _list_content_transcript(): """List-content parts on user turns + tool blocks interleaved.""" msgs = [{"role": "system", "content": "STATIC_PREFIX system rules"}] for i in range(4): msgs.append({ "role": "user", "content": [ {"type": "text", "text": f"question {i}"}, {"type": "text", "text": "attachment context"}, ], }) msgs.append({ "role": "assistant", "content": f"calling tool {i}", "tool_calls": [{"id": f"call_{i}", "type": "function", "function": {"name": "t", "arguments": "{}"}}], }) msgs.append(_tool_block(i)) msgs.append({"role": "assistant", "content": f"answer {i}"}) return msgs def _predecorated_transcript(): """The #90971 regression shape: input a PRIOR pass already decorated. Markers live both top-level and on content parts (both shapes the decorator itself produces), including on a tool block near the tail where the negative-slice budget math went wrong. """ msgs = [{"role": "system", "content": [ {"type": "text", "text": "STATIC_PREFIX early", "cache_control": dict(MARKER)}, {"type": "text", "text": "late suffix", "cache_control": dict(MARKER)}, ]}] for i in range(5): msgs.append({"role": "user", "content": f"u{i}"}) msgs.append({"role": "assistant", "content": f"a{i}"}) # tail: pre-decorated user (top-level marker) + decorated tool block msgs.append({"role": "user", "content": "recent question", "cache_control": dict(MARKER)}) msgs.append({"role": "assistant", "content": "using tool", "tool_calls": [{"id": "call_z", "type": "function", "function": {"name": "t", "arguments": "{}"}}]}) msgs.append(_tool_block(99, decorated=True)) msgs.append({"role": "assistant", "content": "final answer"}) return msgs def _mixed_partially_decorated(): """Stale markers scattered mid-transcript (replayed history).""" msgs = _clean_transcript() msgs[3] = dict(msgs[3], cache_control=dict(MARKER)) msgs[7] = {"role": "user", "content": [ {"type": "text", "text": "mid turn", "cache_control": dict(MARKER)}, ]} return msgs SHAPES = [ pytest.param(_clean_transcript, id="clean-dict-content"), pytest.param(_list_content_transcript, id="list-content-with-tools"), pytest.param(_predecorated_transcript, id="predecorated-90971-regression"), pytest.param(_mixed_partially_decorated, id="mixed-stale-markers"), ] VARIANTS = [ pytest.param({}, id="default"), pytest.param({"static_system_prefix": "STATIC_PREFIX"}, id="split-system"), pytest.param({"native_anthropic": True}, id="native-anthropic"), pytest.param({"cache_ttl": "1h", "static_system_prefix": "STATIC_PREFIX"}, id="1h-ttl"), ] class TestDecorationIdempotenceLaw: """f(f(x)) == f(x), byte-identical, for all transcript shapes (#90971).""" @pytest.mark.parametrize("kwargs", VARIANTS) @pytest.mark.parametrize("make", SHAPES) def test_double_apply_is_byte_identical(self, make, kwargs): once = apply_anthropic_cache_control(make(), **kwargs) twice = apply_anthropic_cache_control( copy.deepcopy(once), **kwargs) assert _canon(twice) == _canon(once), ( "decoration pass is not idempotent: f(f(x)) != f(x)" ) @pytest.mark.parametrize("kwargs", VARIANTS) @pytest.mark.parametrize("make", SHAPES) def test_budget_never_exceeded_across_passes(self, make, kwargs): """No pass — first, second, or fifth — may exceed Anthropic's 4-breakpoint budget, even when the input was already decorated.""" msgs = make() for round_no in range(1, 6): msgs = apply_anthropic_cache_control(msgs, **kwargs) count = _count_cache_markers(msgs, []) assert count <= BUDGET, ( f"round {round_no}: {count} cache_control markers exceed the " f"{BUDGET}-breakpoint budget (the #90971 HTTP-400 class)" ) def test_predecorated_input_does_not_inherit_extra_markers(self): """#90971 core symptom: pre-decorated input must not carry stale markers PLUS fresh ones. Every marker in the output is one the current pass placed — total exactly within budget, and no message carries more than one marker at both levels.""" out = apply_anthropic_cache_control( _predecorated_transcript(), static_system_prefix="STATIC_PREFIX") assert _count_cache_markers(out, []) <= BUDGET for msg in out: top = 1 if "cache_control" in msg else 0 content = msg.get("content") parts = ( sum(1 for p in content if isinstance(p, dict) and "cache_control" in p) if isinstance(content, list) else 0 ) # A single message may legitimately hold two SYSTEM breakpoints # (prefix + suffix in the split-system layout); non-system # messages must never stack top-level + part markers. if msg.get("role") != "system": assert top + parts <= 1, ( f"stacked markers on one message: {msg!r}" )