diff --git a/headroom/cache/prefix_tracker.py b/headroom/cache/prefix_tracker.py index d7e62f436..d54950191 100644 --- a/headroom/cache/prefix_tracker.py +++ b/headroom/cache/prefix_tracker.py @@ -379,16 +379,29 @@ def normalize_message_cache_control( ``messages`` and are left untouched (they still count toward the 4 limit, so holding messages to one breakpoint leaves room for them). + Headroom owns WHERE the breakpoint goes; the client still owns WHAT it says: + the re-placed marker reuses the newest client marker verbatim, so an explicit + ``ttl`` (e.g. ``"1h"``) survives consolidation instead of silently + downgrading to the 5-minute default (#2375). + Only block-style (list) content can carry cache_control; string content is left as-is. Returns the input unchanged when there is nothing to normalize. """ changed = False out: list[dict[str, Any]] = [] last_block_idx = -1 + last_marker: dict[str, Any] | None = None for i, msg in enumerate(messages): content = msg.get("content") if isinstance(msg, dict) else None if isinstance(content, list): - had = any(isinstance(b, dict) and "cache_control" in b for b in content) + had = False + for b in content: + if isinstance(b, dict) and "cache_control" in b: + had = True + # The newest marker in message order is the client's current + # intent (older ones are replay leftovers) — keep it. + if isinstance(b["cache_control"], dict): + last_marker = b["cache_control"] stripped = [ {k: v for k, v in b.items() if k != "cache_control"} if isinstance(b, dict) else b for b in content @@ -403,7 +416,8 @@ def normalize_message_cache_control( if last_block_idx >= 0: msg = out[last_block_idx] content = list(msg["content"]) - content[-1] = {**content[-1], "cache_control": {"type": "ephemeral"}} + marker = dict(last_marker) if last_marker else {"type": "ephemeral"} + content[-1] = {**content[-1], "cache_control": marker} out[last_block_idx] = {**msg, "content": content} changed = True return out if changed else messages diff --git a/tests/test_cache_control_move_bust.py b/tests/test_cache_control_move_bust.py index 087cd38eb..0b74ff92d 100644 --- a/tests/test_cache_control_move_bust.py +++ b/tests/test_cache_control_move_bust.py @@ -188,3 +188,39 @@ def test_normalize_is_noop_when_no_block_markers(): # places exactly one breakpoint (so the prefix gets cached), content stable assert _markers(out) == 1 assert _strip_cache_control(out) == _strip_cache_control(plain) + + +# ── fix-3 (#2375): consolidation must not silently drop the client's ttl ───── + + +def B_ttl(role, text, ttl): + """Block-style message whose marker carries an explicit ttl (1h caching).""" + blk = {"type": "text", "text": text, "cache_control": {"type": "ephemeral", "ttl": ttl}} + return {"role": role, "content": [blk]} + + +def test_normalize_preserves_ttl_of_newest_marker(): + """A 1h-ttl client must not be silently downgraded to the 5m default.""" + msgs = [B("user", "a", cc=True), B_ttl("user", "b", "1h")] + out = normalize_message_cache_control(msgs) + assert _markers(out) == 1 + assert out[-1]["content"][-1]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + + +def test_normalize_newest_marker_wins_over_stale_ttl(): + # Older replayed markers still carry 1h, but the client's NEWEST marker has + # no ttl — the client switched back to the default; don't resurrect 1h. + msgs = [B_ttl("user", "a", "1h"), B_ttl("assistant", "b", "1h"), B("user", "c", cc=True)] + out = normalize_message_cache_control(msgs) + assert _markers(out) == 1 + assert out[-1]["content"][-1]["cache_control"] == {"type": "ephemeral"} + + +def test_normalize_ttl_survives_many_turns(): + """The #2375 scenario: ttl held for one turn, gone on every later turn.""" + conv = [] + for t in range(1, 8): + conv = conv + [B_ttl("user", f"turn-{t}", "1h")] # client always asks 1h + conv = normalize_message_cache_control(conv) + assert _markers(conv) == 1 + assert conv[-1]["content"][-1]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}