Files
hermes-agent/tests/agent/test_review_fork_cache_scope.py
Kyzcreig d159b6e0c3 fix(cache): cache-parity forks get a derived cache scope on xAI
#109964 made same-model cache-parity forks (background review, /btw) inherit
the parent's resolved cache scope. That is the right call for
content-addressed caches (Anthropic, DeepSeek, Gemini) and for OpenAI's
routing-only prompt_cache_key. xAI is different: x-grok-conv-id /
prompt_cache_key select ONE server-side conversation slot, so a fork of
similar size that diverges early evicts the parent's slot and the parent's
next call reads cold.

Measured on grok-4.7 (xai-oauth Responses), ~160k context, fork of similar
size diverging right after the system prompt, parent's next call after the
fork:
  shared scope (current):   1,152 / 162,239 read (2/2 runs)
  derived scope (this PR): 162,176 / 162,239 read (2/2 runs)
  no fork (control):       177,536 / 177,639 read (2/2 runs)

build_cache_parity_fork tags the fork (_prompt_cache_fork_tag). The
resolver derives "<scope>::<tag>" for tagged agents on slot-keyed routes
only (xai, xai-oauth, api.x.ai, OpenRouter x-ai/grok-*). The inherited
scope is kept everywhere else. The tag is applied outside the memo, so a
mid-run provider fallback re-evaluates. OpenRouter's Grok x-grok-conv-id
now honours a fork scope over the ambient affinity/conversation scope
(chat_completions threads cache_scope_id to the profile). session_id and
transcript identity are unchanged.

(cherry picked from commit 40fbb39d978648c76940cfb7835f77a42d3719b0)
2026-09-27 00:38:34 +05:30

191 lines
7.5 KiB
Python

"""Review/side-question forks get their OWN cache scope on slot-keyed caches.
A background-review fork shares the parent's ``session_id`` so content-
addressed caches (Anthropic, DeepSeek, Gemini) and OpenAI-style routing keys
serve it warm. xAI is different: ``x-grok-conv-id`` / ``prompt_cache_key``
select ONE server-side conversation slot, so the fork's divergent request
stream evicts the parent's — measured on xai-oauth, the parent's next call
read 1,152 of ~356k prompt tokens after a fork. The resolver derives
``<scope>::<tag>`` for tagged forks on xAI routes only.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from agent.prompt_cache_scope import (
is_fork_cache_scope,
is_slot_keyed_cache_route,
resolve_prompt_cache_scope,
)
SYS = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
]
def _agent(provider, model="grok-4.3", tag=None, base_url=""):
a = SimpleNamespace(
session_id="parent-sess",
_session_db=None,
provider=provider,
model=model,
base_url=base_url,
)
if tag is not None:
a._prompt_cache_fork_tag = tag
return a
class TestSlotKeyedRoute:
@pytest.mark.parametrize(
"provider,model,base_url",
[
("xai-oauth", "grok-4.3", ""),
("xai", "grok-4", ""),
("custom", "grok-4", "https://api.x.ai/v1"),
("openrouter", "x-ai/grok-4", ""),
],
)
def test_xai_routes_are_slot_keyed(self, provider, model, base_url):
assert is_slot_keyed_cache_route(provider, model, base_url)
@pytest.mark.parametrize(
"provider,model",
[
("anthropic", "claude-opus-4-8"),
("claude-bpr", "claude-opus-4-8"),
("openai-codex", "gpt-5.5"),
("openrouter", "anthropic/claude-sonnet-4.5"),
("gemini", "gemini-3-pro"),
],
)
def test_content_addressed_and_routing_providers_are_not(self, provider, model):
assert not is_slot_keyed_cache_route(provider, model, "")
class TestResolverDerivesForkScope:
def test_parent_on_xai_keeps_its_scope(self):
assert resolve_prompt_cache_scope(_agent("xai-oauth")) == "parent-sess"
def test_review_fork_on_xai_gets_derived_scope(self):
scope = resolve_prompt_cache_scope(_agent("xai-oauth", tag="review"))
assert scope == "parent-sess::review"
assert is_fork_cache_scope(scope)
def test_side_question_fork_gets_its_own_tag(self):
assert (
resolve_prompt_cache_scope(_agent("xai-oauth", tag="side_question"))
== "parent-sess::side_question"
)
@pytest.mark.parametrize(
"provider,model",
[("anthropic", "claude-opus-4-8"), ("openai-codex", "gpt-5.5"),
("openrouter", "anthropic/claude-sonnet-4.5")],
)
def test_fork_on_shared_key_provider_keeps_parent_scope(self, provider, model):
# #109964-style sharing stays where it is a measured win.
assert (
resolve_prompt_cache_scope(_agent(provider, model, tag="review"))
== "parent-sess"
)
def test_memo_is_not_polluted_and_fallback_is_reevaluated(self):
fork = _agent("xai-oauth", tag="review")
assert resolve_prompt_cache_scope(fork) == "parent-sess::review"
assert resolve_prompt_cache_scope(fork) == "parent-sess::review"
fork.provider, fork.model = "anthropic", "claude-opus-4-8" # fallback hop
assert resolve_prompt_cache_scope(fork) == "parent-sess"
def test_inherited_parent_scope_is_derived_on_xai_only(self):
"""#109964 inheritance stays for content-addressed providers, derives on xAI."""
fork = _agent("xai-oauth", tag="review")
fork._inherited_cache_scope = "gwk_parentscope"
assert resolve_prompt_cache_scope(fork) == "gwk_parentscope::review"
fork.provider, fork.model = "anthropic", "claude-opus-4-8"
assert resolve_prompt_cache_scope(fork) == "gwk_parentscope"
def test_gateway_style_single_colon_ids_are_not_fork_scopes(self):
assert not is_fork_cache_scope("agent:main:telegram:dm:42")
class TestTransportWire:
"""What actually goes on the wire for parent vs fork."""
def _xai(self, agent):
from agent.transports.codex import ResponsesApiTransport
return ResponsesApiTransport().build_kwargs(
model="grok-4.3", messages=SYS, tools=[],
session_id=agent.session_id,
cache_scope_id=resolve_prompt_cache_scope(agent),
is_xai_responses=True,
)
def test_xai_fork_sends_distinct_conv_id_and_cache_key(self):
parent = self._xai(_agent("xai-oauth"))
fork = self._xai(_agent("xai-oauth", tag="review"))
assert parent["extra_headers"]["x-grok-conv-id"] == "parent-sess"
assert fork["extra_headers"]["x-grok-conv-id"] == "parent-sess::review"
assert (
parent["extra_body"]["prompt_cache_key"]
!= fork["extra_body"]["prompt_cache_key"]
)
def test_negative_control_untagged_fork_collides(self):
"""Pre-fix shape: same scope -> same slot -> the eviction."""
parent = self._xai(_agent("xai-oauth"))
fork = self._xai(_agent("xai-oauth"))
assert parent["extra_headers"]["x-grok-conv-id"] == fork["extra_headers"]["x-grok-conv-id"]
assert parent["extra_body"]["prompt_cache_key"] == fork["extra_body"]["prompt_cache_key"]
def test_codex_fork_keeps_shared_prompt_cache_key(self):
from agent.transports.codex import ResponsesApiTransport
def build(agent):
return ResponsesApiTransport().build_kwargs(
model="gpt-5.5", messages=SYS, tools=[],
session_id=agent.session_id,
cache_scope_id=resolve_prompt_cache_scope(agent),
is_codex_backend=True,
)
parent = build(_agent("openai-codex", "gpt-5.5"))
fork = build(_agent("openai-codex", "gpt-5.5", tag="review"))
assert parent["prompt_cache_key"] == fork["prompt_cache_key"]
def test_openrouter_grok_fork_overrides_ambient_conversation(self):
from agent.portal_tags import reset_conversation_context, set_conversation_context
from providers import get_provider_profile
p = get_provider_profile("openrouter")
token = set_conversation_context("parent-sess") # fork copies parent's Context
try:
_, parent = p.build_api_kwargs_extras(
model="x-ai/grok-4", session_id="parent-sess",
cache_scope_id="parent-sess",
)
_, fork = p.build_api_kwargs_extras(
model="x-ai/grok-4", session_id="parent-sess",
cache_scope_id="parent-sess::review",
)
finally:
reset_conversation_context(token)
assert parent["extra_headers"]["x-grok-conv-id"] == "parent-sess"
assert fork["extra_headers"]["x-grok-conv-id"] == "parent-sess::review"
def test_chat_completions_threads_cache_scope_to_profile(self):
from agent.transports.chat_completions import ChatCompletionsTransport
from providers import get_provider_profile
kwargs = ChatCompletionsTransport().build_kwargs(
model="x-ai/grok-4", messages=SYS, tools=None,
provider_profile=get_provider_profile("openrouter"),
session_id="parent-sess", cache_scope_id="parent-sess::review",
)
assert kwargs["extra_headers"]["x-grok-conv-id"] == "parent-sess::review"