From 0796a981d7f7c38b6fbb065470671918773db70a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:36:37 -0700 Subject: [PATCH] fix(api_server): bind chat_id (raw session id) on /v1/runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/runs bound only session_key at its _bind_api_server_session call, so tools.async_delegation._current_origin_session_id() — which reads the request-scoped HERMES_SESSION_CHAT_ID — returned "" on that route and runs-originated background delegations stayed forced-sync with no wake target. Bind chat_id/session_id the same way the other agent-entry routes do via _run_agent(). Follow-up to #64998 (sweeper review F2). --- gateway/platforms/api_server.py | 10 ++++++ tests/gateway/test_api_server_runs.py | 45 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index c888fef3d3b1e..e9d79bf71d562 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -5101,7 +5101,17 @@ class APIServerAdapter(BasePlatformAdapter): # environment state. approval_token = set_current_session_key(approval_session_key) session_tokens = self._bind_api_server_session( + # chat_id carries the raw session id (the + # X-Hermes-Session-Id equivalent) exactly like + # the other agent-entry routes bind it via + # _run_agent(). Without it, + # tools.async_delegation reads an empty + # HERMES_SESSION_CHAT_ID on /v1/runs and + # background delegations stay forced-sync + # (no wake target). + chat_id=session_id or "", session_key=approval_session_key, + session_id=session_id or "", ) register_gateway_notify(approval_session_key, _approval_notify) r = agent.run_conversation( diff --git a/tests/gateway/test_api_server_runs.py b/tests/gateway/test_api_server_runs.py index 147a75ab4b30b..ed0240a9ff2b0 100644 --- a/tests/gateway/test_api_server_runs.py +++ b/tests/gateway/test_api_server_runs.py @@ -145,6 +145,51 @@ class TestStartRun: assert status["status"] in {"queued", "running", "completed"} assert status["object"] == "hermes.run" + @pytest.mark.asyncio + async def test_start_binds_chat_id_for_delegation_wake_target(self, adapter): + """/v1/runs must bind the raw session id as the api_server chat_id + (like every other agent-entry route does via _run_agent): the async + delegation dispatch reads HERMES_SESSION_CHAT_ID to pick its wake + self-post target, and an empty binding forces background delegations + on this route back to synchronous execution.""" + app = _create_runs_app(adapter) + captured = {} + + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_create_agent") as mock_create: + mock_agent = MagicMock() + + def _capture_run(user_message=None, conversation_history=None, task_id=None): + from tools.async_delegation import _current_origin_session_id + + captured["origin_session_id"] = _current_origin_session_id() + return {"final_response": "done"} + + mock_agent.run_conversation.side_effect = _capture_run + mock_agent.session_prompt_tokens = 0 + mock_agent.session_completion_tokens = 0 + mock_agent.session_total_tokens = 0 + mock_create.return_value = mock_agent + + resp = await cli.post( + "/v1/runs", + json={"input": "hello", "session_id": "runs-raw-sid"}, + ) + assert resp.status == 202 + data = await resp.json() + run_id = data["run_id"] + + for _ in range(40): + status_resp = await cli.get(f"/v1/runs/{run_id}") + status = await status_resp.json() + if status["status"] == "completed": + break + await asyncio.sleep(0.05) + + assert captured.get("origin_session_id") == "runs-raw-sid", ( + "runs route must bind chat_id so delegation dispatch sees a wake target" + ) + @pytest.mark.asyncio async def test_start_invalid_json_returns_400(self, adapter): app = _create_runs_app(adapter)