Download tests/gateway/test_api_server_declared_conversation.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 28.5 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/gateway/test_api_server_declared_conversation.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/tests/gateway/test_api_server_declared_conversation.py
-
curl -L -o test_api_server_declared_conversation.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/gateway/test_api_server_declared_conversation.py
28.5 kB
| """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" | |
| 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``. | |
| """ | |
| 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.""" | |
| def _resolve_responses(adapter, *, stored, key): | |
| # gateway/platforms/api_server.py::_handle_responses | |
| return stored or adapter._declared_conversation_session(key) or "minted-uuid" | |
| 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). | |
| """ | |
| def _responses_gate(*, stored, key): | |
| # gateway/platforms/api_server.py::_handle_responses | |
| return not stored and bool(key) | |
| 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" | |
| 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.""" | |
| 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 | |
| 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 | |
| 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) | |
| 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. | |
| """ | |
| 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 | |
| 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" | |
| 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.""" | |
| 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 | |
| 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 | |
| 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 | |
| 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" | |