750 lines
28 KiB
Python
750 lines
28 KiB
Python
"""Declared-conversation identity on the API server (#96811).
|
|
|
|
A client that manages its own history has no ``previous_response_id`` chain,
|
|
so ``/v1/responses`` and ``/v1/runs`` used to mint a throwaway physical
|
|
session id per request even when the request declared its conversation with
|
|
``X-Hermes-Session-Key``. Every conversation-affinity hint Hermes sends is
|
|
derived from that physical id — ``prompt_cache_key`` on both OpenAI-wire
|
|
transports, the OpenRouter/Nous sticky ``session_id``, and xAI's
|
|
``x-grok-conv-id`` — so all four re-keyed on every single reply.
|
|
|
|
These tests pin the identity contract itself rather than the four consumers:
|
|
the declared key resolves to one live session, the resolution is fenced by
|
|
the durable conversation boundaries already recorded in
|
|
``sessions.end_reason``, and nothing that does not declare a key changes.
|
|
"""
|
|
|
|
import asyncio
|
|
import types
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import TestClient, TestServer
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms.api_server import (
|
|
APIServerAdapter,
|
|
cors_middleware,
|
|
security_headers_middleware,
|
|
)
|
|
from hermes_state import SessionDB
|
|
|
|
KEY = "agent:main:api_server:room-42:member-7"
|
|
OTHER_KEY = "agent:main:api_server:room-42:member-8"
|
|
SOURCE = "api_server"
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(tmp_path):
|
|
"""An adapter whose lazy SessionDB is pinned to a scratch state.db."""
|
|
a = APIServerAdapter(
|
|
PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": 0, "key": "k"})
|
|
)
|
|
db = SessionDB(tmp_path / "state.db")
|
|
a._session_db = db
|
|
try:
|
|
yield a, db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _seed(db, session_id, *, key=KEY, source=SOURCE):
|
|
db.create_session(session_id=session_id, source=source, model="m")
|
|
if key:
|
|
db.record_gateway_session_peer(session_id, source=source, session_key=key)
|
|
|
|
|
|
class TestDeclaredConversationResolution:
|
|
def test_declared_key_resolves_the_live_session(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert a._declared_conversation_session(KEY) == "sess-live"
|
|
|
|
def test_replies_land_on_one_session(self, adapter):
|
|
"""The defect in one line: same key, three replies, one identity."""
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert {a._declared_conversation_session(KEY) for _ in range(3)} == {"sess-live"}
|
|
|
|
def test_no_declared_key_resolves_nothing(self, adapter):
|
|
"""Undeclared clients keep today's per-request identity."""
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert a._declared_conversation_session(None) is None
|
|
assert a._declared_conversation_session("") is None
|
|
assert a._declared_conversation_session(" ") is None
|
|
|
|
def test_unknown_key_resolves_nothing(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert a._declared_conversation_session(OTHER_KEY) is None
|
|
|
|
def test_distinct_keys_stay_isolated(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-mine", key=KEY)
|
|
_seed(db, "sess-theirs", key=OTHER_KEY)
|
|
assert a._declared_conversation_session(KEY) == "sess-mine"
|
|
assert a._declared_conversation_session(OTHER_KEY) == "sess-theirs"
|
|
|
|
def test_another_platforms_key_is_not_adopted(self, adapter):
|
|
"""The source filter keeps a telegram chat key out of the API server."""
|
|
a, db = adapter
|
|
_seed(db, "sess-telegram", key=KEY, source="telegram")
|
|
assert a._declared_conversation_session(KEY) is None
|
|
|
|
def test_db_failure_degrades_to_a_fresh_id(self, adapter):
|
|
a, _ = adapter
|
|
|
|
class _Boom:
|
|
def find_latest_gateway_session_for_peer(self, **_kw):
|
|
raise RuntimeError("db down")
|
|
|
|
a._session_db = _Boom()
|
|
assert a._declared_conversation_session(KEY) is None
|
|
|
|
def test_missing_db_degrades_to_a_fresh_id(self, adapter, monkeypatch):
|
|
a, _ = adapter
|
|
monkeypatch.setattr(a, "_ensure_session_db", lambda: None)
|
|
assert a._declared_conversation_session(KEY) is None
|
|
|
|
|
|
class TestConversationBoundariesRotate:
|
|
"""The boundary the recovery fence honours is durable in end_reason.
|
|
|
|
#79017/#86733's contract: an affinity scope stays warm across
|
|
continuation and compression rotation, and goes cold on a new
|
|
conversation. ``_RESET_END_REASONS`` is that boundary set, and the
|
|
recovery fence honours every member of it — including the idle/daily
|
|
policy resets, not just ``/new``.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"reason",
|
|
["session_reset", "session_switch", "idle", "daily", "suspended",
|
|
"resume_pending_expired"],
|
|
)
|
|
def test_boundary_rotates_the_conversation(self, adapter, reason):
|
|
a, db = adapter
|
|
_seed(db, "sess-old")
|
|
db.end_session("sess-old", reason)
|
|
assert a._declared_conversation_session(KEY) is None
|
|
|
|
def test_boundary_cannot_be_reached_behind(self, adapter):
|
|
"""A later row wins, and the retired one never comes back (no ABA)."""
|
|
a, db = adapter
|
|
_seed(db, "sess-gen1")
|
|
db.end_session("sess-gen1", "session_reset")
|
|
_seed(db, "sess-gen2")
|
|
assert a._declared_conversation_session(KEY) == "sess-gen2"
|
|
db.end_session("sess-gen2", "session_reset")
|
|
assert a._declared_conversation_session(KEY) is None
|
|
|
|
def test_accidental_end_stays_resumable(self, adapter):
|
|
"""An accidental close is not a conversation boundary."""
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
db.end_session("sess-live", "agent_close")
|
|
assert a._declared_conversation_session(KEY) == "sess-live"
|
|
|
|
|
|
class TestBindDeclaredConversation:
|
|
def test_bind_makes_the_row_resolvable(self, adapter):
|
|
"""Without the bind the row is written unkeyed and is invisible."""
|
|
a, db = adapter
|
|
db.create_session(session_id="sess-new", source=SOURCE, model="m")
|
|
assert a._declared_conversation_session(KEY) is None
|
|
|
|
a._bind_declared_conversation("sess-new", KEY)
|
|
assert a._declared_conversation_session(KEY) == "sess-new"
|
|
|
|
def test_bind_is_a_noop_without_a_key(self, adapter):
|
|
a, db = adapter
|
|
db.create_session(session_id="sess-new", source=SOURCE, model="m")
|
|
a._bind_declared_conversation("sess-new", None)
|
|
a._bind_declared_conversation("sess-new", " ")
|
|
assert db.get_session("sess-new").get("session_key") in (None, "")
|
|
|
|
def test_bind_is_a_noop_without_a_session(self, adapter):
|
|
a, _ = adapter
|
|
a._bind_declared_conversation(None, KEY)
|
|
a._bind_declared_conversation("", KEY)
|
|
assert a._declared_conversation_session(KEY) is None
|
|
|
|
def test_bind_survives_a_db_failure(self, adapter):
|
|
a, _ = adapter
|
|
|
|
class _Boom:
|
|
def record_gateway_session_peer(self, *_a, **_kw):
|
|
raise RuntimeError("db down")
|
|
|
|
a._session_db = _Boom()
|
|
a._bind_declared_conversation("sess-new", KEY) # must not raise
|
|
|
|
def test_bind_follows_a_compression_rotation(self, adapter):
|
|
"""The turn binds the row it ended on; the retired parent follows.
|
|
|
|
``include_compression_ancestors`` keys the whole compression lineage,
|
|
so the next reply resolves the live child rather than its parent.
|
|
"""
|
|
a, db = adapter
|
|
db.create_session(session_id="sess-parent", source=SOURCE, model="m")
|
|
db.end_session("sess-parent", "compression")
|
|
db.create_session(
|
|
session_id="sess-child",
|
|
source=SOURCE,
|
|
model="m",
|
|
parent_session_id="sess-parent",
|
|
)
|
|
a._bind_declared_conversation("sess-child", KEY)
|
|
assert a._declared_conversation_session(KEY) == "sess-child"
|
|
|
|
|
|
class TestOtherStructuresUnaffected:
|
|
"""The bind uses the same routing-peer record every native platform uses.
|
|
|
|
These pin the two places a newly keyed row could leak into: the channel
|
|
directory (contact lists) and the SessionStore's own routing table.
|
|
"""
|
|
|
|
def test_channel_directory_skips_declared_api_rows(self, adapter):
|
|
"""Rows carry no chat_id/origin, so the directory has no entry to build."""
|
|
from gateway import channel_directory
|
|
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
row = db.get_session("sess-live")
|
|
origin = {
|
|
"chat_id": row.get("chat_id"),
|
|
"thread_id": row.get("thread_id"),
|
|
"chat_name": row.get("display_name"),
|
|
}
|
|
assert channel_directory._session_entry_id(origin) is None
|
|
|
|
def test_session_store_routing_table_is_untouched(self, adapter):
|
|
"""SessionStore loads from gateway_routing, not from sessions.session_key."""
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert db.load_gateway_routing_entries() == {}
|
|
|
|
|
|
class TestHandlerWiring:
|
|
"""The precedence the two handlers apply, isolated from aiohttp."""
|
|
|
|
@staticmethod
|
|
def _resolve_responses(adapter, *, stored, key):
|
|
# gateway/platforms/api_server.py::_handle_responses
|
|
return stored or adapter._declared_conversation_session(key) or "minted-uuid"
|
|
|
|
@staticmethod
|
|
def _resolve_runs(adapter, *, body_id, stored, key):
|
|
# gateway/platforms/api_server.py::_handle_runs
|
|
return (
|
|
(body_id or stored)
|
|
or adapter._declared_conversation_session(key)
|
|
or "minted-run-id"
|
|
)
|
|
|
|
def test_response_chain_still_outranks_the_declared_key(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert self._resolve_responses(a, stored="sess-chained", key=KEY) == "sess-chained"
|
|
|
|
def test_declared_key_outranks_a_minted_id(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert self._resolve_responses(a, stored=None, key=KEY) == "sess-live"
|
|
|
|
def test_undeclared_request_still_mints(self, adapter):
|
|
a, _ = adapter
|
|
assert self._resolve_responses(a, stored=None, key=None) == "minted-uuid"
|
|
|
|
def test_runs_body_session_id_still_wins(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert self._resolve_runs(a, body_id="explicit", stored=None, key=KEY) == "explicit"
|
|
|
|
def test_runs_declared_key_outranks_the_run_id(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-live")
|
|
assert self._resolve_runs(a, body_id=None, stored=None, key=KEY) == "sess-live"
|
|
|
|
def test_runs_undeclared_still_uses_the_run_id(self, adapter):
|
|
a, _ = adapter
|
|
assert self._resolve_runs(a, body_id=None, stored=None, key=None) == "minted-run-id"
|
|
|
|
|
|
class TestRunAgentOptIn:
|
|
"""Only the two routes that resolve a declared id record one."""
|
|
|
|
def test_bind_targets_the_rotated_session(self, adapter, monkeypatch):
|
|
"""The finally block binds ``agent.session_id``, not the id it started on."""
|
|
a, _ = adapter
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
a, "_bind_declared_conversation", lambda sid, key: calls.append((sid, key))
|
|
)
|
|
agent = types.SimpleNamespace(session_id="sess-rotated")
|
|
a._bind_declared_conversation(
|
|
getattr(agent, "session_id", None) or "sess-initial", KEY
|
|
)
|
|
assert calls == [("sess-rotated", KEY)]
|
|
|
|
def test_bind_falls_back_when_the_agent_never_started(self, adapter, monkeypatch):
|
|
a, _ = adapter
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
a, "_bind_declared_conversation", lambda sid, key: calls.append((sid, key))
|
|
)
|
|
agent = types.SimpleNamespace(session_id=None)
|
|
a._bind_declared_conversation(
|
|
getattr(agent, "session_id", None) or "sess-initial", KEY
|
|
)
|
|
assert calls == [("sess-initial", KEY)]
|
|
|
|
|
|
class TestBindFollowsPrecedence:
|
|
"""Recording is gated on the declared key actually selecting the session.
|
|
|
|
`record_gateway_session_peer` performs `SET session_key = ?`, so binding a
|
|
session that the response chain or an explicit body id selected would
|
|
rewrite THAT conversation's routing key to this request's header: the
|
|
original conversation could no longer be recovered by its own key, and the
|
|
header key would recover it instead (@andrexibiza on #98811).
|
|
"""
|
|
|
|
@staticmethod
|
|
def _responses_gate(*, stored, key):
|
|
# gateway/platforms/api_server.py::_handle_responses
|
|
return not stored and bool(key)
|
|
|
|
@staticmethod
|
|
def _runs_gate(*, body_id, key):
|
|
# gateway/platforms/api_server.py::_handle_runs
|
|
return not body_id and bool(key)
|
|
|
|
def test_chained_session_does_not_record_the_header_key(self, adapter):
|
|
assert self._responses_gate(stored="sess-chained", key=KEY) is False
|
|
|
|
def test_explicit_body_session_does_not_record_the_header_key(self, adapter):
|
|
assert self._runs_gate(body_id="explicit", key=KEY) is False
|
|
|
|
def test_declared_or_minted_session_records(self, adapter):
|
|
assert self._responses_gate(stored=None, key=KEY) is True
|
|
assert self._runs_gate(body_id=None, key=KEY) is True
|
|
|
|
def test_undeclared_request_records_nothing(self, adapter):
|
|
assert self._responses_gate(stored=None, key=None) is False
|
|
assert self._runs_gate(body_id=None, key=None) is False
|
|
|
|
def test_a_foreign_binding_is_never_overwritten(self, adapter):
|
|
"""Defence in depth behind the gate, at the DB layer."""
|
|
a, db = adapter
|
|
_seed(db, "sess-A", key=KEY)
|
|
|
|
a._bind_declared_conversation("sess-A", OTHER_KEY)
|
|
|
|
assert db.get_session("sess-A")["session_key"] == KEY
|
|
assert a._declared_conversation_session(KEY) == "sess-A"
|
|
assert a._declared_conversation_session(OTHER_KEY) is None
|
|
|
|
def test_rebinding_the_same_key_is_idempotent(self, adapter):
|
|
a, db = adapter
|
|
_seed(db, "sess-A", key=KEY)
|
|
a._bind_declared_conversation("sess-A", KEY)
|
|
assert db.get_session("sess-A")["session_key"] == KEY
|
|
assert a._declared_conversation_session(KEY) == "sess-A"
|
|
|
|
def test_an_unbound_row_still_binds(self, adapter):
|
|
a, db = adapter
|
|
db.create_session(session_id="sess-new", source=SOURCE, model="m")
|
|
a._bind_declared_conversation("sess-new", KEY)
|
|
assert a._declared_conversation_session(KEY) == "sess-new"
|
|
|
|
def test_the_original_conversation_stays_recoverable(self, adapter):
|
|
"""The end-to-end shape of the defect: A must survive a B-keyed turn."""
|
|
a, db = adapter
|
|
_seed(db, "sess-A", key=KEY)
|
|
# A request carrying A's chain plus header key B: the gate refuses to
|
|
# record, and the DB guard refuses even if something else tried.
|
|
assert self._responses_gate(stored="sess-A", key=OTHER_KEY) is False
|
|
a._bind_declared_conversation("sess-A", OTHER_KEY)
|
|
|
|
assert a._declared_conversation_session(KEY) == "sess-A"
|
|
assert a._declared_conversation_session(OTHER_KEY) is None
|
|
|
|
|
|
API_KEY = "test-api-key"
|
|
|
|
|
|
@pytest.fixture
|
|
def live(tmp_path):
|
|
"""A real adapter behind real routes, with a scratch state.db attached."""
|
|
adapter = APIServerAdapter(
|
|
PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": 0, "key": API_KEY})
|
|
)
|
|
db = SessionDB(tmp_path / "state.db")
|
|
adapter._session_db = db
|
|
|
|
mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
|
|
app = web.Application(middlewares=mws)
|
|
app["api_server_adapter"] = adapter
|
|
app.router.add_post("/v1/responses", adapter._handle_responses)
|
|
app.router.add_post("/v1/runs", adapter._handle_runs)
|
|
try:
|
|
yield adapter, db, app
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _spy_run_agent(adapter, seen):
|
|
"""Stand in for _run_agent, faithful to its bind contract.
|
|
|
|
Production records the peer in _run_agent's ``finally`` when the caller
|
|
opted in; mirroring that here keeps the assertion on the real handler's
|
|
decision AND on the row it produces, instead of on a gate expression
|
|
restated inside the test.
|
|
"""
|
|
|
|
async def _fake(**kwargs):
|
|
seen.append(kwargs)
|
|
# AIAgent._ensure_db_session() creates the row during the turn.
|
|
sid = kwargs.get("session_id")
|
|
db = adapter._ensure_session_db()
|
|
if sid and db is not None and db.get_session(sid) is None:
|
|
db.create_session(session_id=sid, source=SOURCE, model="m")
|
|
if kwargs.get("bind_declared_conversation"):
|
|
adapter._bind_declared_conversation(
|
|
kwargs.get("session_id"), kwargs.get("gateway_session_key")
|
|
)
|
|
return (
|
|
{"final_response": "ok", "messages": [], "api_calls": 1},
|
|
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
|
|
)
|
|
|
|
return _fake
|
|
|
|
|
|
def _headers(session_key=None):
|
|
h = {"Authorization": f"Bearer {API_KEY}"}
|
|
if session_key:
|
|
h["X-Hermes-Session-Key"] = session_key
|
|
return h
|
|
|
|
|
|
class TestResponsesHandlerPrecedence:
|
|
"""POST /v1/responses driven end to end, not a restated gate."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declared_key_selects_and_records_the_conversation(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
adapter._run_agent = _spy_run_agent(adapter, seen)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "hi"},
|
|
headers=_headers(KEY),
|
|
)
|
|
assert resp.status == 200
|
|
|
|
minted = seen[0]["session_id"]
|
|
assert seen[0]["bind_declared_conversation"] is True
|
|
assert db.get_session(minted)["session_key"] == KEY
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_second_reply_lands_on_the_same_conversation(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
adapter._run_agent = _spy_run_agent(adapter, seen)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
for _ in range(3):
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "hi"},
|
|
headers=_headers(KEY),
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert len({k["session_id"] for k in seen}) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_undeclared_request_keeps_a_per_request_id(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
adapter._run_agent = _spy_run_agent(adapter, seen)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
for _ in range(2):
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "hi"},
|
|
headers=_headers(),
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert len({k["session_id"] for k in seen}) == 2
|
|
assert all(k["bind_declared_conversation"] is False for k in seen)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_response_chain_outranks_the_header_and_records_nothing(self, live):
|
|
"""The blocker: a chained turn carrying a foreign key must not rebind."""
|
|
adapter, db, app = live
|
|
seen = []
|
|
adapter._run_agent = _spy_run_agent(adapter, seen)
|
|
|
|
# Conversation A already belongs to KEY.
|
|
_seed(db, "sess-A", key=KEY)
|
|
adapter._response_store.put(
|
|
"resp_A",
|
|
{
|
|
"conversation_history": [],
|
|
"session_id": "sess-A",
|
|
"instructions": None,
|
|
},
|
|
)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={
|
|
"model": "hermes-agent",
|
|
"input": "hi",
|
|
"previous_response_id": "resp_A",
|
|
},
|
|
headers=_headers(OTHER_KEY),
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert seen[0]["session_id"] == "sess-A"
|
|
assert seen[0]["bind_declared_conversation"] is False
|
|
# A keeps its own key; the header key cannot recover it.
|
|
assert db.get_session("sess-A")["session_key"] == KEY
|
|
assert adapter._declared_conversation_session(KEY) == "sess-A"
|
|
assert adapter._declared_conversation_session(OTHER_KEY) is None
|
|
|
|
|
|
class TestRunsHandlerPrecedence:
|
|
"""POST /v1/runs driven end to end.
|
|
|
|
/v1/runs owns its agent lifecycle rather than routing through _run_agent,
|
|
so the session it settled on is captured where it builds the agent.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _capture_agent(adapter, seen):
|
|
real = adapter._create_agent
|
|
|
|
def _spy(*a, **kw):
|
|
seen.append(kw)
|
|
agent = types.SimpleNamespace(
|
|
session_id=kw.get("session_id"),
|
|
session_prompt_tokens=0,
|
|
session_completion_tokens=0,
|
|
session_total_tokens=0,
|
|
run_conversation=lambda **_kw: {"final_response": "ok"},
|
|
interrupt=lambda *_a, **_k: None,
|
|
)
|
|
return agent
|
|
|
|
adapter._create_agent = _spy
|
|
return real
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declared_key_selects_the_conversation(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
self._capture_agent(adapter, seen)
|
|
_seed(db, "sess-live", key=KEY)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/runs",
|
|
json={"model": "hermes-agent", "input": "hi"},
|
|
headers=_headers(KEY),
|
|
)
|
|
assert resp.status in (200, 202)
|
|
|
|
assert seen and seen[0]["session_id"] == "sess-live"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_body_session_outranks_the_header_key(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
self._capture_agent(adapter, seen)
|
|
_seed(db, "sess-live", key=KEY)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/runs",
|
|
json={"model": "hermes-agent", "input": "hi",
|
|
"session_id": "explicit-session"},
|
|
headers=_headers(KEY),
|
|
)
|
|
assert resp.status in (200, 202)
|
|
|
|
assert seen and seen[0]["session_id"] == "explicit-session"
|
|
# KEY still resolves to its own conversation, never the explicit one.
|
|
assert adapter._declared_conversation_session(KEY) == "sess-live"
|
|
|
|
|
|
async def _await_run(adapter, run_id, timeout=10.0):
|
|
"""Wait for a /v1/runs worker to finish, so settlement has happened."""
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
if run_id not in adapter._active_run_agents:
|
|
# One more tick so the executor thread's finally can retire.
|
|
await asyncio.sleep(0.05)
|
|
return True
|
|
await asyncio.sleep(0.02)
|
|
return False
|
|
|
|
|
|
def _stub_agent(adapter, session_id, seen):
|
|
"""An agent good enough for the REAL _run_agent to drive end to end.
|
|
|
|
Replacing `_run_agent` itself cannot exercise its settlement block, which
|
|
is where a caller-local name leaked in and raised `NameError` on every
|
|
opted-in bind while mocked tests stayed green (@andrexibiza on #98811).
|
|
Stubbing one layer lower — `_create_agent` — leaves that block real.
|
|
"""
|
|
agent = MagicMock()
|
|
agent.session_id = session_id
|
|
agent.session_prompt_tokens = 0
|
|
agent.session_completion_tokens = 0
|
|
agent.session_total_tokens = 0
|
|
|
|
def _run(**kwargs):
|
|
seen.append(kwargs)
|
|
db = adapter._ensure_session_db()
|
|
# AIAgent._ensure_db_session() creates the row during the turn.
|
|
if db is not None and db.get_session(session_id) is None:
|
|
db.create_session(session_id=session_id, source=SOURCE, model="m")
|
|
return {"final_response": "ok", "messages": [], "api_calls": 1}
|
|
|
|
agent.run_conversation.side_effect = _run
|
|
return agent
|
|
|
|
|
|
class TestRealRunAgentSettlement:
|
|
"""The real `_run_agent` settlement block, not a stand-in for it."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declared_bind_settles_without_raising(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
created = {}
|
|
|
|
def _create(**kw):
|
|
created.update(kw)
|
|
return _stub_agent(adapter, kw["session_id"], seen)
|
|
|
|
adapter._create_agent = _create
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "hi"},
|
|
headers=_headers(KEY),
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
|
|
assert body["status"] == "completed"
|
|
sid = created["session_id"]
|
|
# Settlement ran for real: the row is bound and recoverable.
|
|
assert db.get_session(sid)["session_key"] == KEY
|
|
assert adapter._declared_conversation_session(KEY) == sid
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_two_replies_settle_on_one_conversation(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
ids = []
|
|
|
|
def _create(**kw):
|
|
ids.append(kw["session_id"])
|
|
return _stub_agent(adapter, kw["session_id"], seen)
|
|
|
|
adapter._create_agent = _create
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
for _ in range(2):
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "hi"},
|
|
headers=_headers(KEY),
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert len(set(ids)) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_chained_turn_never_rebinds_through_real_settlement(self, live):
|
|
adapter, db, app = live
|
|
seen = []
|
|
|
|
def _create(**kw):
|
|
return _stub_agent(adapter, kw["session_id"], seen)
|
|
|
|
adapter._create_agent = _create
|
|
|
|
_seed(db, "sess-A", key=KEY)
|
|
adapter._response_store.put(
|
|
"resp_A",
|
|
{"conversation_history": [], "session_id": "sess-A", "instructions": None},
|
|
)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/responses",
|
|
json={"model": "hermes-agent", "input": "hi",
|
|
"previous_response_id": "resp_A"},
|
|
headers=_headers(OTHER_KEY),
|
|
)
|
|
assert resp.status == 200
|
|
|
|
assert db.get_session("sess-A")["session_key"] == KEY
|
|
assert adapter._declared_conversation_session(OTHER_KEY) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runs_explicit_unkeyed_session_stays_unbound(self, live):
|
|
"""The reviewer's exact case: an unkeyed explicit row must not be taken.
|
|
|
|
`_run_sync` bound unconditionally, so an explicit body session_id that
|
|
existed with an empty `session_key` was silently adopted by the header
|
|
key even though the header lost precedence.
|
|
"""
|
|
adapter, db, app = live
|
|
seen = []
|
|
|
|
def _create(**kw):
|
|
return _stub_agent(adapter, kw["session_id"], seen)
|
|
|
|
adapter._create_agent = _create
|
|
|
|
# Explicit session exists and carries NO routing key.
|
|
db.create_session(session_id="explicit-session", source=SOURCE, model="m")
|
|
_seed(db, "sess-live", key=KEY)
|
|
|
|
async with TestClient(TestServer(app)) as cli:
|
|
resp = await cli.post(
|
|
"/v1/runs",
|
|
json={"model": "hermes-agent", "input": "hi",
|
|
"session_id": "explicit-session"},
|
|
headers=_headers(KEY),
|
|
)
|
|
assert resp.status in (200, 202)
|
|
run_id = (await resp.json()).get("run_id")
|
|
assert run_id
|
|
# /v1/runs answers before the turn settles, so the assertion must
|
|
# wait for the worker's finally to run. Without this the test
|
|
# passes on timing rather than on the precedence gate.
|
|
assert await _await_run(adapter, run_id), "run never settled"
|
|
|
|
row = db.get_session("explicit-session")
|
|
assert not (row.get("session_key") or "")
|
|
assert adapter._declared_conversation_session(KEY) == "sess-live"
|