Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contributors/emails/dev@tevs.eu
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cgart
# PR #46165 (expose session binding fields in GET /api/sessions)
3 changes: 2 additions & 1 deletion gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 49 additions & 0 deletions tests/gateway/test_api_server_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
18 changes: 18 additions & 0 deletions website/docs/user-guide/features/api-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<chat_id>:<thread_id>`) 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:<chat_id>:1",
# "chat_id": "<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 \
Expand Down