d63e5d8a10
Both blockers from @andrexibiza's review of 28a2d7f0ee. 1. The generation lookup was not in the same identity domain as recovery. latest_conversation_boundary() selected on session_key alone, while _declared_conversation_session() is qualified by (source, session_key). 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 -- a /new over there rotated this conversation's gwk_ generation while recovery correctly refused to cross the same line, moving the affinity identity out from under a physical identity that had not moved. The boundary read now takes (session_key, source), and the carrier is 'source|key|generation' rather than 'key|generation' -- keying on the string alone would also collapse two same-key conversations from different sources onto one routing key, since this value leaves the process verbatim as OpenRouter's sticky session_id and xAI's x-grok-conv-id. The source comes from the agent's own session row, falling back to the platform the row will be created with before it lands. 2. The declared key's stated lower precedence did not survive settlement. Both handlers let stored_session_id / an explicit body session_id win, then called _bind_declared_conversation() unconditionally. record_gateway_session_peer() does SET session_key = ? across compression ancestors, so a request carrying conversation A's chain plus header key B silently rebound A to B: A could no longer be recovered by its own key, and B recovered A's session. Recording is now gated on the declared key having actually selected or minted the session, on both paths. Behind that gate the bind itself refuses to overwrite a row already bound to a different key, so a future caller cannot reintroduce the same defect by opting in wrongly. test_declaration_outranks_the_lineage_root asserted the pre-qualification contract by comparing a DB-backed agent against a DB-less one; it now makes the stronger statement it was written for -- one declared conversation reached through two different physical ids on the same peer. Refs #96811 Found in review by @andrexibiza, whose analysis located each of these defects and specified what a correct fix had to prove. Co-Authored-By: Andrex Ibiza, MBA <andrexibiza@gmail.com>
592 lines
23 KiB
Python
592 lines
23 KiB
Python
"""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 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
|