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

259 lines
10 KiB
Python

"""Affinity contract on Hermes Studio's group-chat bridge shape (#96811).
Studio's group chat is the reproduction reported on #96811, and it reaches
Hermes as a LIBRARY rather than through the gateway: its Python bridge
constructs ``AIAgent(...)`` directly with a fresh physical ``session_id`` for
every reply, and only then persists the session row.
``tests/agent/test_declared_conversation_scope.py`` pins the declaration
contract on synthetic agent doubles, and
``test_prompt_cache_scope.py::TestPerResponseRunNonceIsolation`` pins the
id-level isolation the scope rule may never break. Neither runs the host's
own construction path, so nothing today would fail if a refactor made that
path stop honouring a declaration. This suite closes that: it builds a real
``AIAgent`` in the bridge's order and states exactly what the host adoption
buys — and what it must not cross.
The shape reproduced here:
1. ``groupRuntimeSessionId(room, profile, name)`` mints
``gc_run_<room>_<profile>_<name>`` truncated to 96 characters plus a fresh
UUID4 hex — a NEW physical id per reply;
2. the agent is constructed before the row exists (same as Studio's pool);
the bridge then writes the row, so the resolution reads a landed row;
3. ``AIAgent(...)`` is constructed with ``platform`` / ``session_id`` /
``session_db`` and no routing identity of any kind.
``groupBridgeSessionId(room, profile, name, sessionSeed, runtimeConfig)`` is
the value that IS stable across those replies. It already exists host-side,
already carries the room, profile, agent name and the room-owned
``sessionSeed``, and is already hashed and length-bounded, so passing it as
``gateway_session_key`` is the entire adoption.
"""
from __future__ import annotations
import hashlib
import re
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from agent.prompt_cache_scope import resolve_prompt_cache_scope
from hermes_state import SessionDB
from run_agent import AIAgent
# The bridge stamps its rows with the ``source`` carried by ``bridge.chat``
# while ``AIAgent.platform`` is the bridge's own platform name; the two need
# not match. Keeping them different here is deliberate — the scope has to
# resolve under the source the row actually landed on.
BRIDGE_ROW_SOURCE = "api_server"
BRIDGE_PLATFORM = "hermes_bridge"
ROOM = "room-7c1f"
PROFILE = "default"
def _runtime_session_id(room: str = ROOM, name: str = "Reviewer") -> str:
"""``groupRuntimeSessionId`` — a new physical id for every reply."""
prefix = f"gc_run_{room}_{PROFILE}_{name}"[:96]
return f"{prefix}_{uuid4().hex}"
def _bridge_session_key(
room: str = ROOM, name: str = "Reviewer", seed: str = "0"
) -> str:
"""``groupBridgeSessionId`` — stable for one conversation in one room."""
raw = f"gc_{room}_{PROFILE}_{name}_{seed}"
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]", "_", raw)
suffix = f"_h_{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
return f"{safe_prefix[: max(0, 120 - len(suffix))]}{suffix}"
@pytest.fixture()
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "state.db")
try:
yield session_db
finally:
session_db.close()
def _bridge_agent(session_db, session_id: str, *, declared_key: str | None):
"""Construct an agent the way the Studio bridge pool does."""
with (
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
):
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
verbose_logging=False,
skip_context_files=True,
skip_memory=True,
session_db=session_db,
session_id=session_id,
platform=BRIDGE_PLATFORM,
gateway_session_key=declared_key,
)
agent.client = MagicMock()
return agent
def _reply_scope(
session_db,
*,
declared_key: str | None,
room: str = ROOM,
name: str = "Reviewer",
row_source: str = BRIDGE_ROW_SOURCE,
) -> str:
"""One Studio reply, in the bridge's own order, returning its scope.
Mirrors the real Studio construction order: ``AIAgent(...)`` is built
first with a fresh physical id, then the bridge persists the row, then
the scope is resolved against the now-landed row. Constructing the
agent before persistence is the part the test exists to pin — the
resolution path must read the row at resolve time, not assume it was
already there when the agent was instantiated.
"""
session_id = _runtime_session_id(room=room, name=name)
# AIAgent first — same order as the Studio bridge pool.
agent = _bridge_agent(session_db, session_id, declared_key=declared_key)
# Then the bridge writes the row, which is when the scope's row reads
# finally have something to find.
session_db.create_session(
session_id=session_id, source=row_source, model="test/model"
)
return resolve_prompt_cache_scope(agent)
class TestStudioBridgeAffinity:
def test_the_undeclared_bridge_shape_rekeys_every_reply(self, db):
"""The reproduction, stated as a permanent negative control.
Hermes must not infer the conversation from the id's syntax — that is
#79017's collision class — so a bridge that declares nothing keeps a
per-reply scope by design. This stays green before and after the host
adopts the key, and it is why the adoption has to happen host-side.
"""
scopes = {_reply_scope(db, declared_key=None) for _ in range(3)}
assert len(scopes) == 3
def test_a_declared_conversation_holds_one_affinity_across_replies(self, db):
"""Consecutive replies keep one affinity though their ids differ."""
key = _bridge_session_key()
scopes = {_reply_scope(db, declared_key=key) for _ in range(3)}
assert len(scopes) == 1
assert scopes.pop().startswith("gwk_")
def test_the_scope_never_carries_the_room_or_member(self, db):
"""The scope leaves the process verbatim as a provider routing key."""
scope = _reply_scope(db, declared_key=_bridge_session_key())
assert ROOM not in scope
assert "Reviewer" not in scope
assert len(scope) <= 64
def test_a_different_member_or_room_gets_a_different_affinity(self, db):
"""Two members of one room, and two rooms, never share a bucket."""
reviewer = _reply_scope(db, declared_key=_bridge_session_key(name="Reviewer"))
planner = _reply_scope(
db, declared_key=_bridge_session_key(name="Planner"), name="Planner"
)
other_room = _reply_scope(
db, declared_key=_bridge_session_key(room="room-99ab"), room="room-99ab"
)
assert len({reviewer, planner, other_room}) == 3
def test_a_new_session_seed_rotates_the_affinity(self, db):
"""Studio's own conversation boundary is the room-owned sessionSeed.
A new conversation in the room mints a new seed, and the seed is part
of the declared key, so the next conversation starts on a cold bucket
without Hermes having to observe a reset of its own.
"""
first = _reply_scope(db, declared_key=_bridge_session_key(seed="0"))
second = _reply_scope(db, declared_key=_bridge_session_key(seed="1"))
assert first != second
def test_the_row_source_is_the_authority_not_the_platform(self, db):
"""Equal keys under different sources never share one scope.
The declared key is caller-supplied and may legally repeat across
hosts sharing one database, and this value leaves the process as a
provider routing key, so the carrier is qualified by the source the
row actually landed under.
"""
key = _bridge_session_key()
as_api_server = _reply_scope(db, declared_key=key, row_source="api_server")
as_other = _reply_scope(db, declared_key=key, row_source="studio_native")
assert as_api_server != as_other
def test_a_tool_child_on_the_same_declaration_keeps_its_own_scope(self, db):
"""#79161 survives this construction path.
A tool child inherits nothing from the bridge today, but were a host
ever to hand one the room's declared key, the row's own fork marker
still keeps it off the conversation's bucket.
"""
key = _bridge_session_key()
conversation = _reply_scope(db, declared_key=key)
child_id = _runtime_session_id()
child = _bridge_agent(db, child_id, declared_key=key)
db.create_session(session_id=child_id, source="tool", model="test/model")
assert resolve_prompt_cache_scope(child) == child_id
assert child_id != conversation
def test_a_background_review_fork_never_borrows_the_declaration(self, db):
"""Background review clones the live runtime, declared key included."""
key = _bridge_session_key()
session_id = _runtime_session_id()
agent = _bridge_agent(db, session_id, declared_key=key)
agent._persist_disabled = True
db.create_session(
session_id=session_id, source=BRIDGE_ROW_SOURCE, model="test/model"
)
assert resolve_prompt_cache_scope(agent) == session_id
class TestUndeclaredBridgeIsUnchanged:
"""Byte-identical behaviour for a bridge that declares nothing."""
def test_the_undeclared_shape_resolves_to_the_physical_id(self, db):
session_id = _runtime_session_id()
agent = _bridge_agent(db, session_id, declared_key=None)
db.create_session(
session_id=session_id, source=BRIDGE_ROW_SOURCE, model="test/model"
)
assert resolve_prompt_cache_scope(agent) == session_id
def test_an_undeclared_compression_rotation_still_walks_its_lineage(self, db):
root = _runtime_session_id()
db.create_session(session_id=root, source=BRIDGE_ROW_SOURCE, model="test/model")
db.end_session(root, "compression")
rotated = _runtime_session_id()
db.create_session(
session_id=rotated,
source=BRIDGE_ROW_SOURCE,
model="test/model",
parent_session_id=root,
)
agent = _bridge_agent(db, rotated, declared_key=None)
assert resolve_prompt_cache_scope(agent) == root