Files
hermes-agent/tests/agent/test_declared_conversation_scope.py
joaomarcos 6b9b3e0145 chore(cache): take the pre-merge cleanups on the declared conversation scope
@teknium1's maintainer-side review found no blocking defect on 09004753c9 and
listed five cleanups. All five are here.

1. scratch/repro_96811.py is deleted. It would have landed on main as a
   tracked file: scratch/ is not gitignored and has never existed on main, so
   this PR was creating the directory. Nothing referenced the probe, and
   TestConversationGenerationRotates / TestGenerationSurvivesPruning /
   TestPeerIdentityIsSourceQualified already carry all four of its stages, so
   it is dropped rather than parked under tests/.

2. Upgrade notes are written into this commit body (below) and the PR body.
   There is no committed changelog to add them to: scripts/release.py
   generates .release_notes.md from commit SUBJECTS at release time, and
   .gitignore keeps that file out of the tree.

3. declared_conversation_scope() now reads the sessions row ONCE. The fork
   verdict and the source the peer queries match on both live on that row, and
   asking for them separately read it twice per resolution. The new
   SessionDB.declared_scope_identity() returns the pair and keeps the marker
   rules beside is_explicit_fork_child() instead of re-implementing them in the
   caller. A SessionDB that does not expose the combined view keeps the
   original two-call path, so nothing that predates it changes behaviour --
   including the three doubles that certify the fail-closed contract, which are
   untouched. TestOneIdentityReadPerResolution pins the single read, the
   two-call fallback, the fail-closed degrade and the fork refusal; removing
   the fold turns the first of those red.

   The third read stays: the generation lives in conversation_generations, a
   different table, and cannot be folded into a sessions lookup.

4. _declared_conversation_session() documents the concurrent first-turn race.
   Two simultaneous first requests on one declared key can each miss the
   lookup, mint a row and both bind, because each row is unkeyed at bind time
   and the mismatch guard does not fire. That converges rather than crossing:
   both rows carry the same key under the same source, so the lookup returns
   the later one for every subsequent reply and the earlier row is an abandoned
   transcript, never another conversation's identity.

   The same docstring still claimed the generation was durable in
   sessions.end_reason and that "nothing here needs a counter". That stopped
   being true in 09004753c9, which moved the generation into
   conversation_generations precisely because deriving it from prunable session
   rows was ABA. Corrected, along with the same stale sentence on
   TestConversationBoundariesRotate.

5. conversation_generations rows are now documented as deliberately never
   collected, rather than merely uncollected. Dropping one resets that peer to
   "no generation", so its next boundary writes 1 again and re-issues a gwk_
   scope a retired conversation already used -- the exact ABA the table exists
   to close. Worth stating because the repo already carries both patterns a
   maintainer would extend: delete_session() cascades to messages, and
   gateway_hygiene_state is already swept by session_key.

Upgrade notes, one-time on merge:

- One cold prompt-cache bucket per keyed conversation. Every gateway platform
  declares gateway_session_key, so each keyed conversation's affinity scope
  moves once from its compression-lineage root session id to the gwk_ hash.
  One cache miss per live conversation, on its next turn only.
- hermes status counts more sessions. A declared API conversation is now
  recorded as a keyed row and appears in "Active: N session(s)" where it was
  invisible. Those sessions already existed; only their visibility changes.
- A database upgraded mid-conversation starts with no generation and takes its
  first from the next boundary written, so a conversation that reset before the
  upgrade shares its predecessor's scope once. One warm bucket, never a crossed
  identity.

Verified on this head: 55 in test_declared_conversation_scope.py (51 + 4 new),
33 in test_prompt_cache_scope.py, 49 in test_api_server_declared_conversation.py,
25 in test_api_server_runs.py, 109 in test_api_server.py, 12 in
test_cross_process_turn_lease.py, and 526 across test_hermes_state.py +
tests/hermes_state/ + tests/state/. ruff clean.

Found in review by @teknium1.

Refs #96811
2026-09-01 02:14:35 -07:00

810 lines
31 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 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