{"repo":"NousResearch/hermes-agent","public_id":"3438","display_label":"API session persistence and cross-client visibility","summary":"These items address persisting chat sessions created via the /v1/chat/completions API endpoint and surfacing them consistently across clients (WebUI and CLI), including session ID headers and shared session storage.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":4478},"resolution":{"state":"partially_resolved","by":"pr-7127"},"freshness":{"state":"stale","last_verified_at":"2026-04-01T18:41:32+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":3438,"pr_number":4478,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3438,"title":"[Feature]: Persist /v1/chat/completions sessions so Open WebUI chats appear in `hermes sessions list`","url":null,"state":"closed"},{"kind":"issue","number":58518,"title":"WebUI resume list can hide active CLI sessions before final output","url":null,"state":"closed"},{"kind":"pr","number":3439,"title":"feat(api_server): persist /v1/chat/completions sessions via X-Chat-ID header","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4478","number":4478}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":4478,"title":"feat(api-server): add X-Hermes-Session-Id header for session continuity","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","tests/gateway/test_api_server.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -2,7 +2,7 @@\n OpenAI-compatible API server platform adapter.\n \n Exposes an HTTP server with endpoints:\n-- POST /v1/chat/completions        — OpenAI Chat Completions format (stateless)\n+- POST /v1/chat/completions        — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)\n - POST /v1/responses               — OpenAI Responses API format (stateful via previous_response_id)\n - GET  /v1/responses/{response_id} — Retrieve a stored response\n - DELETE /v1/responses/{response_id} — Delete a stored response","@@ -300,6 +300,7 @@ def __init__(self, config: PlatformConfig):\n         self._runner: Optional[\"web.AppRunner\"] = None\n         self._site: Optional[\"web.TCPSite\"] = None\n         self._response_store = ResponseStore()\n+        self._session_db: Optional[Any] = None  # Lazy-init SessionDB for session continuity\n \n     @staticmethod\n     def _parse_cors_origins(value: Any) -> tuple[str, ...]:","@@ -496,7 +497,23 @@ async def _handle_chat_completions(self, request: \"web.Request\") -> \"web.Respons\n                 status=400,\n             )\n \n-        session_id = str(uuid.uuid4())\n+        # Allow caller to continue an existing session by passing X-Hermes-Session-Id.\n+        # When provided, history is loaded from state.db instead of from the request body.\n+        provided_session_id = request.headers.get(\"X-Hermes-Session-Id\", \"\").strip()\n+        if provided_session_id:\n+            session_id = provided_session_id\n+            try:\n+                if self._session_db is None:\n+                    from hermes_state import SessionDB\n+                    self._session_db = SessionDB()\n+                history = self._session_db.get_messages_as_conversation(session_id)\n+            except Exception as e:\n+                logger.warning(\"Failed to load session history for %s: %s\", session_id, e)\n+                history = []\n+        else:\n+            session_id = str(uuid.uuid4())\n+            # history already set from request body above\n+\n         completion_id = f\"chatcmpl-{uuid.uuid4().hex[:29]}\"\n         model_name = body.get(\"model\", \"hermes-agent\")\n         created = int(time.time())","@@ -540,7 +557,7 @@ def _on_tool_progress(name, preview, args):\n \n             return await self._write_sse_chat_completion(\n                 request, completion_id, model_name, created, _stream_q,\n-                agent_task, agent_ref,\n+                agent_task, agent_ref, session_id=session_id,\n             )\n \n         # Non-streaming: run the agent (with optional Idempotency-Key)","@@ -599,11 +616,11 @@ async def _compute_completion():\n             },\n         }\n \n-        return web.json_response(response_data)\n+        return web.json_response(response_data, headers={\"X-Hermes-Session-Id\": session_id})\n \n     async def _write_sse_chat_completion(\n         self, request: \"web.Request\", completion_id: str, model: str,\n-        created: int, stream_q, agent_task, agent_ref=None,\n+        created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,\n     ) -> \"web.StreamResponse\":\n         \"\"\"Write real streaming SSE from agent's stream_delta_callback queue.\n ","@@ -620,6 +637,8 @@ async def _write_sse_chat_completion(\n         cors = self._cors_headers_for_origin(origin) if origin else None\n         if cors:\n             sse_headers.update(cors)\n+        if session_id:\n+            sse_headers[\"X-Hermes-Session-Id\"] = session_id\n         response = web.StreamResponse(status=200, headers=sse_headers)\n         await response.prepare(request)\n "]},{"file":"tests/gateway/test_api_server.py","hunks":["@@ -1576,3 +1576,110 @@ async def test_conversation_store_false_no_mapping(self, adapter):\n                 assert resp.status == 200\n                 # Conversation mapping should NOT be set since store=false\n                 assert adapter._response_store.get_conversation(\"ephemeral-chat\") is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# X-Hermes-Session-Id header (session continuity)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestSessionIdHeader:\n+    @pytest.mark.asyncio\n+    async def test_new_session_response_includes_session_id_header(self, adapter):\n+        \"\"\"Without X-Hermes-Session-Id, a new session is created and returned in the header.\"\"\"\n+        mock_result = {\"final_response\": \"Hello!\", \"messages\": [], \"api_calls\": 1}\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (mock_result, {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0})\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    json={\"model\": \"hermes-agent\", \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]},\n+                )\n+            assert resp.status == 200\n+            assert resp.headers.get(\"X-Hermes-Session-Id\") is not None\n+\n+    @pytest.mark.asyncio\n+    async def test_provided_session_id_is_used_and_echoed(self, adapter):\n+        \"\"\"When X-Hermes-Session-Id is provided, it's passed to the agent and echoed in the response.\"\"\"\n+        mock_result = {\"final_response\": \"Continuing!\", \"messages\": [], \"api_calls\": 1}\n+        mock_db = MagicMock()\n+        mock_db.get_messages_as_conversation.return_value = [\n+            {\"role\": \"user\", \"content\": \"previous message\"},\n+            {\"role\": \"assistant\", \"content\": \"previous reply\"},\n+        ]\n+        adapter._session_db = mock_db\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (mock_result, {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0})\n+\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    headers={\"X-Hermes-Session-Id\": \"my-session-123\"},\n+                    json={\"model\": \"hermes-agent\", \"messages\": [{\"role\": \"user\", \"content\": \"Continue\"}]},\n+                )\n+\n+            assert resp.status == 200\n+            assert resp.headers.get(\"X-Hermes-Session-Id\") == \"my-session-123\"\n+            call_kwargs = mock_run.call_args.kwargs\n+            assert call_kwargs[\"session_id\"] == \"my-session-123\"\n+\n+    @pytest.mark.asyncio\n+    async def test_provided_session_id_loads_history_from_db(self, adapter):\n+        \"\"\"When X-Hermes-Session-Id is provided, history comes from SessionDB not request body.\"\"\"\n+        mock_result = {\"final_response\": \"OK\", \"messages\": [], \"api_calls\": 1}\n+        db_history = [\n+            {\"role\": \"user\", \"content\": \"stored message 1\"},\n+            {\"role\": \"assistant\", \"content\": \"stored reply 1\"},\n+        ]\n+        mock_db = MagicMock()\n+        mock_db.get_messages_as_conversation.return_value = db_history\n+        adapter._session_db = mock_db\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run:\n+                mock_run.return_value = (mock_result, {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0})\n+\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    headers={\"X-Hermes-Session-Id\": \"existing-session\"},\n+                    # Request body has different history — should be ignored\n+                    json={\n+                        \"model\": \"hermes-agent\",\n+                        \"messages\": [\n+                            {\"role\": \"user\", \"content\": \"old msg from client\"},\n+                            {\"role\": \"assistant\", \"content\": \"old reply from client\"},\n+                            {\"role\": \"user\", \"content\": \"new question\"},\n+                        ],\n+                    },\n+                )\n+\n+            assert resp.status == 200\n+            call_kwargs = mock_run.call_args.kwargs\n+            # History must come from DB, not from the request body\n+            assert call_kwargs[\"conversation_history\"] == db_history\n+            assert call_kwargs[\"user_message\"] == \"new question\"\n+\n+    @pytest.mark.asyncio\n+    async def test_db_failure_falls_back_to_empty_history(self, adapter):\n+        \"\"\"If SessionDB raises, history falls back to empty and request still succeeds.\"\"\"\n+        mock_result = {\"final_response\": \"OK\", \"messages\": [], \"api_calls\": 1}\n+        # Simulate DB failure: _session_db is None and SessionDB() constructor raises\n+        adapter._session_db = None\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            with patch.object(adapter, \"_run_agent\", new_callable=AsyncMock) as mock_run, \\\n+                 patch(\"hermes_state.SessionDB\", side_effect=Exception(\"DB unavailable\")):\n+                mock_run.return_value = (mock_result, {\"input_tokens\": 0, \"output_tokens\": 0, \"total_tokens\": 0})\n+\n+                resp = await cli.post(\n+                    \"/v1/chat/completions\",\n+                    headers={\"X-Hermes-Session-Id\": \"some-session\"},\n+                    json={\"model\": \"hermes-agent\", \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]},\n+                )\n+\n+            assert resp.status == 200\n+            call_kwargs = mock_run.call_args.kwargs\n+            assert call_kwargs[\"conversation_history\"] == []\n+            assert call_kwargs[\"session_id\"] == \"some-session\"\n"]}]},{"kind":"pr","number":7127,"title":"fix(api-server): share one Docker container across all API conversations","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true}],"trust":{"default_tier":"verified","includes_suggested":false}}