Files
aiturk-hermes-ide/tests/agent/test_declared_conversation_scope.py

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