diff --git a/contributors/emails/dev@tevs.eu b/contributors/emails/dev@tevs.eu new file mode 100644 index 000000000000..24f28f9ac69b --- /dev/null +++ b/contributors/emails/dev@tevs.eu @@ -0,0 +1,2 @@ +cgart +# PR #46165 (expose session binding fields in GET /api/sessions) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 4ea5a6eef735..0000e734452f 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2766,7 +2766,8 @@ def _session_db_unavailable() -> "web.Response": def _session_response(session: Dict[str, Any]) -> Dict[str, Any]: """Return a stable, client-safe session representation.""" safe_keys = ( - "id", "source", "user_id", "model", "title", "started_at", "ended_at", "end_reason", + "id", "source", "user_id", "session_key", "chat_id", "chat_type", "thread_id", + "model", "title", "started_at", "ended_at", "end_reason", "message_count", "tool_call_count", "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens", "estimated_cost_usd", "actual_cost_usd", "api_call_count", "parent_session_id", "last_active", "preview", diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index f1dc90c74038..e3da6b64f67e 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -3237,3 +3237,59 @@ def __init__(self, **kwargs): ) adapter._create_agent(session_id="s2", gateway_session_key="ch") assert captured[1]["model"] == "anthropic/claude-opus-4.6" + + +class TestListSessionsRoute: + """GET /api/sessions serializes rows through _session_response.""" + + @staticmethod + def _app_with_sessions_route(adapter): + app = _create_app(adapter) + app.router.add_get("/api/sessions", adapter._handle_list_sessions) + return app + + @pytest.mark.asyncio + async def test_list_sessions_exposes_gateway_binding(self, adapter, tmp_path): + """The list envelope is {object, data, ...} and each row carries the + session binding fields (session_key/chat_id/chat_type/thread_id) — + null for rows created without a gateway origin — while sensitive + snapshots stay reduced to existence flags.""" + from hermes_state import SessionDB + + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + session_id="keyed-session", + source="telegram", + session_key="agent:main:telegram:group:-1001234567890:1", + chat_id="-1001234567890", + chat_type="group", + thread_id="1", + system_prompt="secret prompt", + ) + db.create_session(session_id="unkeyed-session", source="cli") + adapter._session_db = db + + try: + app = self._app_with_sessions_route(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/api/sessions?limit=20&offset=0") + assert resp.status == 200 + body = await resp.json() + finally: + db.close() + + assert body["object"] == "list" + rows = {s["id"]: s for s in body["data"]} + + keyed = rows["keyed-session"] + assert keyed["session_key"] == "agent:main:telegram:group:-1001234567890:1" + assert keyed["chat_id"] == "-1001234567890" + assert keyed["chat_type"] == "group" + assert keyed["thread_id"] == "1" + assert "system_prompt" not in keyed + assert keyed["has_system_prompt"] is True + + unkeyed = rows["unkeyed-session"] + for key in ("session_key", "chat_id", "chat_type", "thread_id"): + assert key in unkeyed + assert unkeyed[key] is None diff --git a/tests/gateway/test_api_server_normalize.py b/tests/gateway/test_api_server_normalize.py index e20399cf5bf4..47d547c68265 100644 --- a/tests/gateway/test_api_server_normalize.py +++ b/tests/gateway/test_api_server_normalize.py @@ -36,3 +36,52 @@ def test_empty_text_parts_filtered(self): assert _normalize_chat_content(content) == "actual" + + +class TestSessionResponse: + """_session_response exposes a stable, client-safe set of session fields.""" + + def test_session_binding_fields_returned(self): + """session_key and the structured chat binding are client-safe + metadata: external consumers use them to map a live session to the + chat/group/thread it originated from.""" + from gateway.platforms.api_server import APIServerAdapter + + session = { + "id": "sess_1", + "user_id": "u1", + "session_key": "agent:main:telegram:group:-1001234567890:1", + "chat_id": "-1001234567890", + "chat_type": "group", + "thread_id": "1", + } + payload = APIServerAdapter._session_response(session) + assert payload["session_key"] == "agent:main:telegram:group:-1001234567890:1" + assert payload["chat_id"] == "-1001234567890" + assert payload["chat_type"] == "group" + assert payload["thread_id"] == "1" + + def test_binding_fields_omitted_when_absent(self): + """Keys missing from the source row are not fabricated.""" + from gateway.platforms.api_server import APIServerAdapter + + payload = APIServerAdapter._session_response({"id": "sess_1", "user_id": "u1"}) + for key in ("session_key", "chat_id", "chat_type", "thread_id"): + assert key not in payload + + def test_unsafe_keys_stay_stripped(self): + """Sensitive snapshots are not echoed back, only existence flags.""" + from gateway.platforms.api_server import APIServerAdapter + + session = { + "id": "sess_1", + "session_key": "key-abc", + "system_prompt": "secret prompt", + "model_config": {"k": "v"}, + } + payload = APIServerAdapter._session_response(session) + assert "system_prompt" not in payload + assert "model_config" not in payload + assert payload["has_system_prompt"] is True + assert payload["has_model_config"] is True + assert payload["session_key"] == "key-abc" diff --git a/website/docs/user-guide/features/api-server.md b/website/docs/user-guide/features/api-server.md index 32d586ffb99f..3a2eb285b798 100644 --- a/website/docs/user-guide/features/api-server.md +++ b/website/docs/user-guide/features/api-server.md @@ -618,6 +618,24 @@ External UIs can manage Hermes sessions over REST without standing up the dashbo `/v1/capabilities` advertises the full surface via `session_*` feature flags and `endpoints.session_*` entries so external UIs can detect support and fall back safely. Inline images are supported in `chat` and `chat/stream` payloads (multimodal-aware path). +Each session object returned by `GET /api/sessions` includes its gateway binding: `session_key` (the stable routing key, e.g. `agent:main:telegram:group::`) plus the structured `chat_id`, `chat_type`, and `thread_id` fields. All four are `null` for sessions created without a gateway origin (plain CLI sessions, or rows predating the fields). Use them to map a live session back to the exact channel it came from instead of inferring from `user_id` alone — for Telegram in particular, `user_id` is the sender's personal ID and is identical across DMs and group messages. `session_key` is the same value callers can set on inbound requests via the [`X-Hermes-Session-Key`](#long-term-memory-scoping-x-hermes-session-key) header. + +```bash +# list sessions, including each session's gateway binding +curl "http://localhost:8642/api/sessions?limit=20" \ + -H "Authorization: Bearer $API_SERVER_KEY" +# → {"object": "list", +# "data": [ +# {"id": "abc123", "source": "telegram", "user_id": "456", +# "session_key": "agent:main:telegram:group::1", +# "chat_id": "", "chat_type": "group", "thread_id": "1", ...}, +# {"id": "def456", "source": "cli", "user_id": null, +# "session_key": null, "chat_id": null, "chat_type": null, +# "thread_id": null, ...} +# ], +# "limit": 20, "offset": 0, "has_more": false} +``` + ```bash # fork a session and run one turn curl -X POST http://localhost:8642/api/sessions/$ID/fork \