Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,749 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user