Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,809 @@
|
||||
"""Host-declared conversation scope on the affinity-key path (issue #96811).
|
||||
|
||||
A host that mints one physical ``session_id`` per RESPONSE re-keys every
|
||||
conversation-affinity hint Hermes sends — ``prompt_cache_key`` on both
|
||||
OpenAI-wire transports, the OpenRouter/Nous sticky ``session_id``, and xAI's
|
||||
``x-grok-conv-id`` — so the conversation never lands back on the routing
|
||||
bucket it warmed. Hermes cannot infer the logical conversation from the id's
|
||||
syntax (#79017's failure class), but it does not have to: the host declares
|
||||
it through ``gateway_session_key`` (the ``X-Hermes-Session-Key`` /
|
||||
``build_session_key`` per-chat key).
|
||||
|
||||
These tests pin the declaration contract and the two boundaries it must not
|
||||
cross: explicit fork children (``/branch``, delegate, tool) and
|
||||
background-review forks, which share the parent's chat key but are separate
|
||||
conversations under #79161.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.portal_tags import (
|
||||
get_affinity_scope,
|
||||
reset_affinity_scope,
|
||||
reset_conversation_context,
|
||||
set_affinity_scope,
|
||||
set_conversation_context,
|
||||
)
|
||||
from agent.prompt_cache_scope import (
|
||||
declared_conversation_scope,
|
||||
declared_conversation_scope_safe,
|
||||
resolve_prompt_cache_scope,
|
||||
)
|
||||
from agent.transports.codex import _cache_scope_from_session_id, _content_cache_key
|
||||
from hermes_state import SessionDB
|
||||
|
||||
# One room member, two consecutive replies: the Studio group-chat shape
|
||||
# (``gc_run_<room>_<profile>_<name>`` truncated to 96 chars + a per-response
|
||||
# UUID4 hex) and the ``POST /v1/responses`` shape (a bare ``str(uuid4())``).
|
||||
RUN_1 = "gc_run_room7_default_Reviewer_11111111111141118111111111111111"
|
||||
RUN_2 = "gc_run_room7_default_Reviewer_22222222222242228222222222222222"
|
||||
CHAT_KEY = "agent:main:telegram:group:-100123:456"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
try:
|
||||
yield session_db
|
||||
finally:
|
||||
session_db.close()
|
||||
|
||||
|
||||
def _agent(session_id, session_db=None, key=None):
|
||||
return SimpleNamespace(
|
||||
session_id=session_id,
|
||||
_session_db=session_db,
|
||||
_gateway_session_key=key,
|
||||
)
|
||||
|
||||
|
||||
def _sticky_key(session_id):
|
||||
from providers import get_provider_profile
|
||||
|
||||
return get_provider_profile("openrouter").build_extra_body(session_id=session_id)[
|
||||
"session_id"
|
||||
]
|
||||
|
||||
|
||||
def _grok_headers(session_id):
|
||||
from providers import get_provider_profile
|
||||
|
||||
_extra_body, top_level = get_provider_profile("openrouter").build_api_kwargs_extras(
|
||||
model="x-ai/grok-4",
|
||||
session_id=session_id,
|
||||
)
|
||||
return top_level["extra_headers"]
|
||||
|
||||
|
||||
class TestDeclaredConversationScope:
|
||||
def test_per_response_ids_resolve_to_one_declared_scope(self, db):
|
||||
"""THE fix: two replies of one conversation share one scope."""
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
db.create_session(RUN_2, source="api_server")
|
||||
|
||||
first = resolve_prompt_cache_scope(_agent(RUN_1, db, CHAT_KEY))
|
||||
second = resolve_prompt_cache_scope(_agent(RUN_2, db, CHAT_KEY))
|
||||
|
||||
assert first == second
|
||||
assert first not in (RUN_1, RUN_2)
|
||||
|
||||
def test_distinct_declarations_stay_isolated(self, db):
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
other = _agent(RUN_1, db, "agent:main:telegram:group:-100123:999")
|
||||
|
||||
assert resolve_prompt_cache_scope(
|
||||
_agent(RUN_1, db, CHAT_KEY)
|
||||
) != resolve_prompt_cache_scope(other)
|
||||
|
||||
def test_scope_never_carries_the_raw_key(self, db):
|
||||
"""The scope leaves the process verbatim (sticky id, x-grok-conv-id).
|
||||
|
||||
A session id is a Hermes-internal token; a session KEY embeds the
|
||||
platform, chat and user identifiers, so it is hashed first.
|
||||
"""
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
scope = resolve_prompt_cache_scope(_agent(RUN_1, db, CHAT_KEY))
|
||||
|
||||
assert scope.startswith("gwk_")
|
||||
assert "telegram" not in scope
|
||||
assert "-100123" not in scope
|
||||
assert len(scope) <= 64 # provider key budget
|
||||
|
||||
def test_no_declaration_keeps_lineage_behavior(self, db):
|
||||
"""Unchanged for every host that keeps one id per conversation."""
|
||||
db.create_session("root-sess", source="webui")
|
||||
db.end_session("root-sess", "compression")
|
||||
db.create_session("rotated-1", source="webui", parent_session_id="root-sess")
|
||||
|
||||
assert resolve_prompt_cache_scope(_agent("rotated-1", db)) == "root-sess"
|
||||
assert declared_conversation_scope(_agent("rotated-1", db)) is None
|
||||
|
||||
def test_declaration_outranks_the_lineage_root(self, db):
|
||||
"""Both are stable; the declared key is stable across MORE (per-response
|
||||
ids), so it wins rather than being a fallback."""
|
||||
db.create_session("root-sess", source="webui")
|
||||
db.end_session("root-sess", "compression")
|
||||
db.create_session("rotated-1", source="webui", parent_session_id="root-sess")
|
||||
|
||||
scope = resolve_prompt_cache_scope(_agent("rotated-1", db, CHAT_KEY))
|
||||
# Same declared conversation reached through a different physical id
|
||||
# on the same peer — the property the lineage walk cannot provide.
|
||||
db.create_session("rotated-2", source="webui")
|
||||
assert scope == resolve_prompt_cache_scope(_agent("rotated-2", db, CHAT_KEY))
|
||||
assert scope != "root-sess"
|
||||
assert scope.startswith("gwk_")
|
||||
|
||||
def test_branch_child_ignores_the_shared_chat_key(self, db):
|
||||
"""/branch keys off session_id, not the chat key — #79161 isolation."""
|
||||
db.create_session("root-sess", source="telegram")
|
||||
db.create_session(
|
||||
"branch-child",
|
||||
source="telegram",
|
||||
parent_session_id="root-sess",
|
||||
model_config={"_branched_from": "root-sess"},
|
||||
)
|
||||
|
||||
assert (
|
||||
resolve_prompt_cache_scope(_agent("branch-child", db, CHAT_KEY))
|
||||
== "branch-child"
|
||||
)
|
||||
assert (
|
||||
resolve_prompt_cache_scope(_agent("root-sess", db, CHAT_KEY))
|
||||
!= "branch-child"
|
||||
)
|
||||
|
||||
def test_delegate_child_ignores_the_declaration(self, db):
|
||||
db.create_session("parent-sess", source="telegram")
|
||||
db.create_session(
|
||||
"delegate-child",
|
||||
source="telegram",
|
||||
parent_session_id="parent-sess",
|
||||
model_config={"_delegate_from": "parent-sess"},
|
||||
)
|
||||
|
||||
assert (
|
||||
resolve_prompt_cache_scope(_agent("delegate-child", db, CHAT_KEY))
|
||||
== "delegate-child"
|
||||
)
|
||||
|
||||
def test_tool_child_ignores_the_declaration(self, db):
|
||||
db.create_session("parent-sess", source="telegram")
|
||||
db.create_session("tool-child", source="tool", parent_session_id="parent-sess")
|
||||
|
||||
assert (
|
||||
resolve_prompt_cache_scope(_agent("tool-child", db, CHAT_KEY))
|
||||
== "tool-child"
|
||||
)
|
||||
|
||||
def test_background_review_fork_ignores_the_declaration(self, db):
|
||||
"""The review fork clones the live runtime, key included."""
|
||||
db.create_session("live-sess", source="telegram")
|
||||
agent = _agent("review-fork", db, CHAT_KEY)
|
||||
agent._persist_disabled = True
|
||||
|
||||
assert declared_conversation_scope(agent) is None
|
||||
assert resolve_prompt_cache_scope(agent) == "review-fork"
|
||||
|
||||
def test_fork_check_failure_degrades_to_the_physical_scope(self):
|
||||
"""A transient DB error must not merge a fork onto its parent's key."""
|
||||
|
||||
class BoomDB:
|
||||
def is_explicit_fork_child(self, sid):
|
||||
raise RuntimeError("db exploded")
|
||||
|
||||
def get_compression_lineage(self, sid):
|
||||
return [sid]
|
||||
|
||||
agent = _agent("maybe-fork", BoomDB(), CHAT_KEY)
|
||||
assert declared_conversation_scope(agent) is None
|
||||
assert resolve_prompt_cache_scope(agent) == "maybe-fork"
|
||||
|
||||
def test_declaration_applies_before_the_row_lands(self, db):
|
||||
"""turn_context resolves before _ensure_db_session persists the row."""
|
||||
agent = _agent(RUN_1, db, CHAT_KEY)
|
||||
assert resolve_prompt_cache_scope(agent).startswith("gwk_")
|
||||
|
||||
def test_blank_declarations_are_no_declaration(self, db):
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
for blank in (None, "", " "):
|
||||
assert declared_conversation_scope(_agent(RUN_1, db, blank)) is None
|
||||
|
||||
def test_safe_variant_never_raises(self):
|
||||
class ExplodingAgent:
|
||||
@property
|
||||
def _gateway_session_key(self):
|
||||
raise RuntimeError("hostile property")
|
||||
|
||||
assert declared_conversation_scope_safe(ExplodingAgent()) is None
|
||||
assert declared_conversation_scope_safe(
|
||||
_agent("sess", None, CHAT_KEY)
|
||||
).startswith("gwk_")
|
||||
|
||||
|
||||
class TestOneIdentityReadPerResolution:
|
||||
"""The fork verdict and the row's source come from one ``sessions`` read.
|
||||
|
||||
Resolution is memoized per transcript segment, so this was never on the
|
||||
per-API-call hot path (#79017) — but reading the same row twice per
|
||||
resolution was one read too many (@teknium1 on #98811), and a ``SessionDB``
|
||||
that predates the combined view has to keep the path it had.
|
||||
"""
|
||||
|
||||
def test_the_identity_row_is_read_once(self, db):
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
reads = []
|
||||
real_get_session = db.get_session
|
||||
|
||||
def counted(session_id):
|
||||
reads.append(session_id)
|
||||
return real_get_session(session_id)
|
||||
|
||||
db.get_session = counted
|
||||
try:
|
||||
scope = declared_conversation_scope(_agent(RUN_1, db, CHAT_KEY))
|
||||
finally:
|
||||
del db.get_session
|
||||
|
||||
assert scope.startswith("gwk_")
|
||||
assert reads == [RUN_1]
|
||||
|
||||
def test_a_db_without_the_combined_view_keeps_the_two_call_path(self):
|
||||
calls = []
|
||||
|
||||
class LegacyDB:
|
||||
def is_explicit_fork_child(self, sid):
|
||||
calls.append(("fork", sid))
|
||||
return False
|
||||
|
||||
def get_session(self, sid):
|
||||
calls.append(("row", sid))
|
||||
return {"source": "telegram"}
|
||||
|
||||
def latest_conversation_boundary(self, key, source):
|
||||
return None
|
||||
|
||||
scope = declared_conversation_scope(_agent("sess-legacy", LegacyDB(), CHAT_KEY))
|
||||
|
||||
assert scope is not None and scope.startswith("gwk_")
|
||||
assert calls == [("fork", "sess-legacy"), ("row", "sess-legacy")]
|
||||
|
||||
def test_the_combined_read_still_fails_closed(self):
|
||||
"""A fork must never merge onto its parent's key on a DB failure."""
|
||||
|
||||
class BoomDB:
|
||||
def declared_scope_identity(self, sid):
|
||||
raise RuntimeError("db exploded")
|
||||
|
||||
def get_compression_lineage(self, sid):
|
||||
return [sid]
|
||||
|
||||
agent = _agent("maybe-fork", BoomDB(), CHAT_KEY)
|
||||
|
||||
assert declared_conversation_scope(agent) is None
|
||||
assert resolve_prompt_cache_scope(agent) == "maybe-fork"
|
||||
|
||||
def test_the_combined_read_still_refuses_a_fork(self, db):
|
||||
db.create_session("tool-child", source="tool")
|
||||
|
||||
assert declared_conversation_scope(_agent("tool-child", db, CHAT_KEY)) is None
|
||||
|
||||
|
||||
class TestPromptCacheKeyStability:
|
||||
"""The reported symptom, at the wire layer: one conversation, one key."""
|
||||
|
||||
INSTRUCTIONS = "You are Reviewer in room7."
|
||||
TOOLS = [{"type": "function", "name": "terminal"}]
|
||||
|
||||
def _key_for(self, agent):
|
||||
scope = _cache_scope_from_session_id(resolve_prompt_cache_scope(agent))
|
||||
return _content_cache_key(self.INSTRUCTIONS, self.TOOLS, scope)
|
||||
|
||||
def test_key_survives_a_per_response_id(self, db):
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
db.create_session(RUN_2, source="api_server")
|
||||
|
||||
assert self._key_for(_agent(RUN_1, db, CHAT_KEY)) == self._key_for(
|
||||
_agent(RUN_2, db, CHAT_KEY)
|
||||
)
|
||||
|
||||
def test_key_still_churns_without_a_declaration(self, db):
|
||||
"""Nothing is inferred from the id itself — the #79017 rule holds."""
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
db.create_session(RUN_2, source="api_server")
|
||||
|
||||
assert self._key_for(_agent(RUN_1, db)) != self._key_for(_agent(RUN_2, db))
|
||||
|
||||
def test_codex_transport_key_matches_across_responses(self, db):
|
||||
from agent.transports.codex import ResponsesApiTransport
|
||||
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
db.create_session(RUN_2, source="api_server")
|
||||
transport = ResponsesApiTransport()
|
||||
base = dict(
|
||||
model="gpt-5.5",
|
||||
messages=[
|
||||
{"role": "system", "content": self.INSTRUCTIONS},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
tools=[],
|
||||
)
|
||||
|
||||
def key(session_id):
|
||||
scope = resolve_prompt_cache_scope(_agent(session_id, db, CHAT_KEY))
|
||||
return transport.build_kwargs(
|
||||
**base, session_id=session_id, cache_scope_id=scope
|
||||
)["prompt_cache_key"]
|
||||
|
||||
assert key(RUN_1) == key(RUN_2)
|
||||
|
||||
def test_chat_completions_key_matches_across_responses(self, db):
|
||||
from agent.transports.chat_completions import _add_prompt_cache_key
|
||||
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
db.create_session(RUN_2, source="api_server")
|
||||
messages = [{"role": "system", "content": self.INSTRUCTIONS}]
|
||||
|
||||
def key(session_id):
|
||||
kwargs: dict = {}
|
||||
_add_prompt_cache_key(
|
||||
kwargs,
|
||||
messages=messages,
|
||||
tools=None,
|
||||
supports_prompt_cache_key=True,
|
||||
session_id=session_id,
|
||||
cache_scope_id=resolve_prompt_cache_scope(
|
||||
_agent(session_id, db, CHAT_KEY)
|
||||
),
|
||||
)
|
||||
return kwargs["prompt_cache_key"]
|
||||
|
||||
assert key(RUN_1) == key(RUN_2)
|
||||
|
||||
def test_transcript_identity_is_not_rewritten(self, db):
|
||||
"""#57012: the session header still carries the physical id."""
|
||||
from agent.transports.codex import ResponsesApiTransport
|
||||
|
||||
db.create_session(RUN_1, source="api_server")
|
||||
kwargs = ResponsesApiTransport().build_kwargs(
|
||||
model="gpt-5.5",
|
||||
messages=[{"role": "system", "content": self.INSTRUCTIONS}],
|
||||
tools=[],
|
||||
session_id=RUN_1,
|
||||
cache_scope_id=resolve_prompt_cache_scope(_agent(RUN_1, db, CHAT_KEY)),
|
||||
is_codex_backend=True,
|
||||
)
|
||||
assert kwargs["extra_headers"]["session_id"] == RUN_1
|
||||
|
||||
|
||||
class TestProviderStickyKeys:
|
||||
"""OpenRouter / Nous sticky ids and x-grok-conv-id read the same scope."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_context(self):
|
||||
affinity = set_affinity_scope(None)
|
||||
conversation = set_conversation_context(None)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_conversation_context(conversation)
|
||||
reset_affinity_scope(affinity)
|
||||
|
||||
def test_declared_scope_pins_the_sticky_key(self):
|
||||
scope = declared_conversation_scope(_agent(RUN_1, None, CHAT_KEY))
|
||||
token = set_affinity_scope(scope)
|
||||
try:
|
||||
first = _sticky_key(RUN_1)
|
||||
second = _sticky_key(RUN_2)
|
||||
finally:
|
||||
reset_affinity_scope(token)
|
||||
|
||||
assert first == second == scope
|
||||
|
||||
def test_without_a_declaration_the_conversation_id_still_wins(self):
|
||||
"""Delegate trees keep sharing their parent's sticky key."""
|
||||
conversation = set_conversation_context("parent-root")
|
||||
try:
|
||||
assert get_affinity_scope() is None
|
||||
assert _sticky_key("delegate-child") == "parent-root"
|
||||
finally:
|
||||
reset_conversation_context(conversation)
|
||||
|
||||
def test_grok_conv_id_follows_the_declared_scope(self):
|
||||
scope = declared_conversation_scope(_agent(RUN_1, None, CHAT_KEY))
|
||||
token = set_affinity_scope(scope)
|
||||
try:
|
||||
headers = _grok_headers(RUN_1)
|
||||
headers_next = _grok_headers(RUN_2)
|
||||
finally:
|
||||
reset_affinity_scope(token)
|
||||
|
||||
assert headers["x-grok-conv-id"] == headers_next["x-grok-conv-id"] == scope
|
||||
|
||||
def test_nous_sticky_key_follows_the_declared_scope(self):
|
||||
from providers import get_provider_profile
|
||||
|
||||
scope = declared_conversation_scope(_agent(RUN_1, None, CHAT_KEY))
|
||||
token = set_affinity_scope(scope)
|
||||
try:
|
||||
body = get_provider_profile("nous").build_extra_body(session_id=RUN_1)
|
||||
body_next = get_provider_profile("nous").build_extra_body(session_id=RUN_2)
|
||||
finally:
|
||||
reset_affinity_scope(token)
|
||||
|
||||
assert body["session_id"] == body_next["session_id"] == scope
|
||||
|
||||
|
||||
class TestConversationGenerationRotates:
|
||||
"""The declared key must not outlive the conversation it names.
|
||||
|
||||
``gateway_session_key`` is a per-CHAT identifier: ``reset_session()``
|
||||
mints a fresh physical id on ``/new`` and keeps the key, and the
|
||||
idle/daily/suspended policy resets do the same. Hashing the key alone
|
||||
would map the conversation before a reset and the one after it onto ONE
|
||||
affinity scope, violating the #79017/#86733 contract.
|
||||
|
||||
The generation is read from the boundary those resets already write
|
||||
(``_RESET_END_REASONS`` on the outgoing row), so nothing new is persisted
|
||||
and the two fences cannot drift.
|
||||
"""
|
||||
|
||||
KEY = "agent:main:telegram:dm:123"
|
||||
|
||||
def _keyed(self, db, session_id):
|
||||
db.create_session(
|
||||
session_id=session_id,
|
||||
source="telegram",
|
||||
session_key=self.KEY,
|
||||
)
|
||||
return _agent(session_id, db, self.KEY)
|
||||
|
||||
def test_new_rotates_the_declared_scope(self, db):
|
||||
"""The exact reproduction that blocked this PR, now green.
|
||||
|
||||
``/new`` ends the outgoing row with ``session_reset`` and mints a new
|
||||
physical id under the same chat key; before the generation qualifier
|
||||
both sides hashed to one ``gwk_`` value.
|
||||
"""
|
||||
before = self._keyed(db, "sess-A")
|
||||
scope_before = resolve_prompt_cache_scope(before)
|
||||
|
||||
db.end_session("sess-A", "session_reset")
|
||||
after = self._keyed(db, "sess-B")
|
||||
|
||||
assert scope_before.startswith("gwk_")
|
||||
assert resolve_prompt_cache_scope(after).startswith("gwk_")
|
||||
assert resolve_prompt_cache_scope(after) != scope_before
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
["session_reset", "session_switch", "idle", "daily", "suspended",
|
||||
"resume_pending_expired"],
|
||||
)
|
||||
def test_every_reset_boundary_rotates(self, db, reason):
|
||||
"""Policy auto-resets are conversation replacements too.
|
||||
|
||||
A hand-rolled counter incremented only in ``reset_session()`` would
|
||||
leave these on the previous generation; reading the durable boundary
|
||||
covers the whole set by construction.
|
||||
"""
|
||||
first = self._keyed(db, f"sess-{reason}-1")
|
||||
scope_first = resolve_prompt_cache_scope(first)
|
||||
db.end_session(f"sess-{reason}-1", reason)
|
||||
second = self._keyed(db, f"sess-{reason}-2")
|
||||
assert resolve_prompt_cache_scope(second) != scope_first
|
||||
|
||||
def test_generations_never_roll_back(self, db):
|
||||
"""Three conversations on one key produce three distinct scopes."""
|
||||
scopes = []
|
||||
for i in range(3):
|
||||
agent = self._keyed(db, f"sess-gen{i}")
|
||||
scopes.append(resolve_prompt_cache_scope(agent))
|
||||
db.end_session(f"sess-gen{i}", "session_reset")
|
||||
assert len(set(scopes)) == 3
|
||||
|
||||
def test_per_response_ids_still_share_one_scope(self, db):
|
||||
"""The whole point of the PR survives the fix.
|
||||
|
||||
A host that mints one id per RESPONSE writes no boundary, so every
|
||||
reply reads the same (empty) generation and lands on one scope.
|
||||
"""
|
||||
scopes = {
|
||||
resolve_prompt_cache_scope(self._keyed(db, f"gc_run_{i}"))
|
||||
for i in range(4)
|
||||
}
|
||||
assert len(scopes) == 1
|
||||
assert next(iter(scopes)).startswith("gwk_")
|
||||
|
||||
def test_an_accidental_end_is_not_a_boundary(self, db):
|
||||
"""Only intentional breaks rotate; a crash-close keeps the scope warm."""
|
||||
first = self._keyed(db, "sess-live")
|
||||
scope_first = resolve_prompt_cache_scope(first)
|
||||
db.end_session("sess-live", "agent_close")
|
||||
second = self._keyed(db, "sess-resumed")
|
||||
assert resolve_prompt_cache_scope(second) == scope_first
|
||||
|
||||
def test_another_chats_reset_does_not_rotate_this_one(self, db):
|
||||
"""The boundary is read per declared key, never globally."""
|
||||
mine = self._keyed(db, "sess-mine")
|
||||
scope_mine = resolve_prompt_cache_scope(mine)
|
||||
|
||||
other_key = "agent:main:telegram:dm:999"
|
||||
db.create_session(
|
||||
session_id="sess-other", source="telegram", session_key=other_key
|
||||
)
|
||||
db.end_session("sess-other", "session_reset")
|
||||
|
||||
again = self._keyed(db, "sess-mine-2")
|
||||
assert resolve_prompt_cache_scope(again) == scope_mine
|
||||
|
||||
def test_scope_never_carries_the_raw_key_or_boundary(self, db):
|
||||
agent = self._keyed(db, "sess-A")
|
||||
db.end_session("sess-A", "session_reset")
|
||||
rotated = self._keyed(db, "sess-B")
|
||||
scope = resolve_prompt_cache_scope(rotated)
|
||||
assert self.KEY not in scope
|
||||
assert "telegram" not in scope
|
||||
assert scope.startswith("gwk_")
|
||||
assert len(scope) == len("gwk_") + 24
|
||||
|
||||
def test_generation_read_failure_degrades_to_the_physical_scope(self):
|
||||
"""Fail closed: an unqualified key would span a /new."""
|
||||
|
||||
class BoomDB:
|
||||
def is_explicit_fork_child(self, sid):
|
||||
return False
|
||||
|
||||
def latest_conversation_boundary(self, key):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
def get_compression_lineage(self, sid):
|
||||
return []
|
||||
|
||||
agent = _agent("sess-A", BoomDB(), self.KEY)
|
||||
assert declared_conversation_scope(agent) is None
|
||||
|
||||
def test_a_db_without_the_lookup_keeps_the_declaration(self, db):
|
||||
"""Forward/backward compatible: no boundary API means no boundary."""
|
||||
|
||||
class LegacyDB:
|
||||
def is_explicit_fork_child(self, sid):
|
||||
return False
|
||||
|
||||
def get_compression_lineage(self, sid):
|
||||
return []
|
||||
|
||||
agent = _agent("sess-A", LegacyDB(), self.KEY)
|
||||
scope = declared_conversation_scope(agent)
|
||||
assert scope is not None and scope.startswith("gwk_")
|
||||
|
||||
def test_a_backwards_clock_does_not_reuse_a_generation(self, db):
|
||||
"""An NTP correction between two resets must not merge them.
|
||||
|
||||
``MAX(ended_at)`` alone would keep returning the earlier, larger
|
||||
timestamp; the boundary COUNT is what separates them.
|
||||
"""
|
||||
first = self._keyed(db, "sess-clock-1")
|
||||
scope_first = resolve_prompt_cache_scope(first)
|
||||
db.end_session("sess-clock-1", "session_reset")
|
||||
|
||||
second = self._keyed(db, "sess-clock-2")
|
||||
scope_second = resolve_prompt_cache_scope(second)
|
||||
db.end_session("sess-clock-2", "session_reset")
|
||||
# The clock went backwards: this boundary lands BEFORE the first one.
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = ("
|
||||
" SELECT MIN(ended_at) FROM sessions WHERE ended_at IS NOT NULL"
|
||||
") - 60 WHERE id = ?",
|
||||
("sess-clock-2",),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
third = self._keyed(db, "sess-clock-3")
|
||||
scope_third = resolve_prompt_cache_scope(third)
|
||||
assert len({scope_first, scope_second, scope_third}) == 3
|
||||
|
||||
|
||||
class TestPeerIdentityIsSourceQualified:
|
||||
"""The generation and the carrier use the same identity tuple as recovery.
|
||||
|
||||
``X-Hermes-Session-Key`` accepts any authenticated caller-supplied string,
|
||||
so an API conversation may legally carry the same key as a Telegram row in
|
||||
one database. Keying on the string alone let a ``/new`` on that unrelated
|
||||
row rotate this conversation's affinity identity, while
|
||||
``find_latest_gateway_session_for_peer`` correctly refused to cross the
|
||||
same line — the physical identity stayed put while the affinity identity
|
||||
moved under it (@andrexibiza on #98811).
|
||||
"""
|
||||
|
||||
KEY = "shared-key-string"
|
||||
|
||||
def _row(self, db, sid, source):
|
||||
db.create_session(session_id=sid, source=source, session_key=self.KEY)
|
||||
return SimpleNamespace(
|
||||
session_id=sid, _session_db=db, _gateway_session_key=self.KEY,
|
||||
platform=source,
|
||||
)
|
||||
|
||||
def test_a_foreign_sources_reset_does_not_rotate_this_conversation(self, db):
|
||||
mine = self._row(db, "api-1", "api_server")
|
||||
before = resolve_prompt_cache_scope(mine)
|
||||
|
||||
# Same key string, different platform, reset only over there.
|
||||
self._row(db, "tg-1", "telegram")
|
||||
db.end_session("tg-1", "session_reset")
|
||||
|
||||
assert resolve_prompt_cache_scope(self._row(db, "api-2", "api_server")) == before
|
||||
|
||||
def test_our_own_reset_still_rotates(self, db):
|
||||
mine = self._row(db, "api-1", "api_server")
|
||||
before = resolve_prompt_cache_scope(mine)
|
||||
db.end_session("api-1", "session_reset")
|
||||
assert resolve_prompt_cache_scope(self._row(db, "api-2", "api_server")) != before
|
||||
|
||||
def test_equal_keys_under_different_sources_never_share_a_scope(self, db):
|
||||
mine = resolve_prompt_cache_scope(self._row(db, "api-1", "api_server"))
|
||||
theirs = resolve_prompt_cache_scope(self._row(db, "tg-1", "telegram"))
|
||||
assert mine != theirs
|
||||
assert mine.startswith("gwk_") and theirs.startswith("gwk_")
|
||||
|
||||
def test_the_boundary_read_is_peer_scoped(self, db):
|
||||
db.create_session(session_id="tg-1", source="telegram", session_key=self.KEY)
|
||||
db.end_session("tg-1", "session_reset")
|
||||
assert db.latest_conversation_boundary(self.KEY, "telegram") is not None
|
||||
assert db.latest_conversation_boundary(self.KEY, "api_server") is None
|
||||
assert db.latest_conversation_boundary(self.KEY, "") is None
|
||||
|
||||
|
||||
class TestGenerationSurvivesPruning:
|
||||
"""A generation derived from prunable rows cannot prove non-reuse.
|
||||
|
||||
`delete_session()` orphans surviving children and deletes the selected
|
||||
row, and bulk prune selects ended rows, so an aggregate over
|
||||
`_RESET_END_REASONS` boundaries can return a pair it already emitted:
|
||||
`(1, T1) -> (2, T2) -> delete boundary B -> (1, T1)`, handing a new
|
||||
conversation a retired affinity identity (@andrexibiza on #98811).
|
||||
|
||||
The counter therefore lives in `conversation_generations`, outside session
|
||||
history, and only ever increments.
|
||||
"""
|
||||
|
||||
KEY = "agent:main:telegram:dm:777"
|
||||
SOURCE = "telegram"
|
||||
|
||||
def _keyed(self, db, sid):
|
||||
db.create_session(session_id=sid, source=self.SOURCE, session_key=self.KEY)
|
||||
return SimpleNamespace(
|
||||
session_id=sid, _session_db=db, _gateway_session_key=self.KEY,
|
||||
platform=self.SOURCE,
|
||||
)
|
||||
|
||||
def _gen(self, db):
|
||||
return db.latest_conversation_boundary(self.KEY, self.SOURCE)
|
||||
|
||||
def test_deleting_the_newest_boundary_does_not_roll_back(self, db):
|
||||
a = self._keyed(db, "s-a")
|
||||
scope_a = resolve_prompt_cache_scope(a)
|
||||
db.end_session("s-a", "session_reset")
|
||||
|
||||
b = self._keyed(db, "s-b")
|
||||
scope_b = resolve_prompt_cache_scope(b)
|
||||
db.end_session("s-b", "session_reset")
|
||||
assert self._gen(db) == 2
|
||||
|
||||
db.delete_session("s-b") # prune the newest boundary
|
||||
assert self._gen(db) == 2 # counter is outside that rowset
|
||||
|
||||
c = self._keyed(db, "s-c")
|
||||
scope_c = resolve_prompt_cache_scope(c)
|
||||
assert len({scope_a, scope_b, scope_c}) == 3
|
||||
|
||||
def test_deleting_every_boundary_does_not_roll_back(self, db):
|
||||
a = self._keyed(db, "s-a")
|
||||
scope_a = resolve_prompt_cache_scope(a)
|
||||
db.end_session("s-a", "session_reset")
|
||||
db.delete_session("s-a")
|
||||
|
||||
b = self._keyed(db, "s-b")
|
||||
assert resolve_prompt_cache_scope(b) != scope_a
|
||||
|
||||
def test_a_backwards_clock_then_a_prune_still_cannot_repeat(self, db):
|
||||
"""The reviewer's exact shape: (1,T1) -> (2,T1) -> delete -> (1,T1)."""
|
||||
a = self._keyed(db, "s-a")
|
||||
scope_a = resolve_prompt_cache_scope(a)
|
||||
db.end_session("s-a", "session_reset")
|
||||
|
||||
b = self._keyed(db, "s-b")
|
||||
scope_b = resolve_prompt_cache_scope(b)
|
||||
db.end_session("s-b", "session_reset")
|
||||
# Clock went backwards: this boundary lands before the first one.
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = ("
|
||||
" SELECT MIN(ended_at) FROM sessions WHERE ended_at IS NOT NULL"
|
||||
") - 60 WHERE id = ?",
|
||||
("s-b",),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.delete_session("s-b")
|
||||
|
||||
c = self._keyed(db, "s-c")
|
||||
assert len({scope_a, scope_b, resolve_prompt_cache_scope(c)}) == 3
|
||||
|
||||
def test_only_real_boundaries_advance_it(self, db):
|
||||
self._keyed(db, "s-a")
|
||||
db.end_session("s-a", "compression")
|
||||
assert self._gen(db) is None
|
||||
db.create_session(session_id="s-b", source=self.SOURCE, session_key=self.KEY)
|
||||
db.end_session("s-b", "agent_close")
|
||||
assert self._gen(db) is None
|
||||
|
||||
def test_a_repeated_end_does_not_double_count(self, db):
|
||||
"""end_session no-ops on an ended row; the first reason wins."""
|
||||
self._keyed(db, "s-a")
|
||||
db.end_session("s-a", "session_reset")
|
||||
db.end_session("s-a", "session_reset")
|
||||
db.end_session("s-a", "idle")
|
||||
assert self._gen(db) == 1
|
||||
|
||||
def test_promote_advances_it_too(self, db):
|
||||
"""/new and the policy resets promote rather than end_session."""
|
||||
self._keyed(db, "s-a")
|
||||
db.end_session("s-a", "agent_close")
|
||||
assert self._gen(db) is None
|
||||
assert db.promote_to_session_reset("s-a", "session_reset") is True
|
||||
assert self._gen(db) == 1
|
||||
|
||||
def test_an_unkeyed_row_advances_nothing(self, db):
|
||||
db.create_session(session_id="s-bare", source=self.SOURCE)
|
||||
db.end_session("s-bare", "session_reset")
|
||||
assert self._gen(db) is None
|
||||
|
||||
def test_the_counter_is_peer_scoped(self, db):
|
||||
self._keyed(db, "s-a")
|
||||
db.end_session("s-a", "session_reset")
|
||||
assert self._gen(db) == 1
|
||||
assert db.latest_conversation_boundary(self.KEY, "api_server") is None
|
||||
|
||||
|
||||
class TestSourceOverrideDomain:
|
||||
"""The scope is memoized immediately, so the source must be right first.
|
||||
|
||||
``_agent_source`` used ``agent.platform`` before the row landed while
|
||||
persistence uses ``_session_source_for_agent``, which honors
|
||||
``HERMES_SESSION_SOURCE``. Under an override both sides of a ``/new``
|
||||
queried the platform domain, missed the boundary stored under the
|
||||
override, and hashed the same scope (@andrexibiza on #98811).
|
||||
"""
|
||||
|
||||
KEY = "agent:main:telegram:dm:888"
|
||||
|
||||
def test_the_pre_row_source_matches_persistence(self, db, monkeypatch):
|
||||
from agent.prompt_cache_scope import _agent_source
|
||||
|
||||
monkeypatch.setenv("HERMES_SESSION_SOURCE", "override-src")
|
||||
agent = SimpleNamespace(
|
||||
session_id="s-none", _session_db=db, _gateway_session_key=self.KEY,
|
||||
platform="telegram",
|
||||
)
|
||||
assert _agent_source(agent, "", db) == "override-src"
|
||||
|
||||
def test_new_rotates_under_a_source_override(self, db, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_SESSION_SOURCE", "override-src")
|
||||
|
||||
def keyed(sid):
|
||||
db.create_session(
|
||||
session_id=sid, source="override-src", session_key=self.KEY
|
||||
)
|
||||
return SimpleNamespace(
|
||||
session_id=sid, _session_db=db,
|
||||
_gateway_session_key=self.KEY, platform="telegram",
|
||||
)
|
||||
|
||||
before = resolve_prompt_cache_scope(keyed("s-a"))
|
||||
db.end_session("s-a", "session_reset")
|
||||
assert resolve_prompt_cache_scope(keyed("s-b")) != before
|
||||
Reference in New Issue
Block a user