Coverage for src/lilbee/server/handlers/sessions.py: 100%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Session route handlers: list, get, rename, forget.
3Reads and mutations go through the process ``SessionStore`` on the services
4container. A missing session id surfaces as a 404.
5"""
7from __future__ import annotations
9from collections.abc import Generator
10from contextlib import contextmanager
12from litestar.exceptions import ClientException, NotFoundException
13from litestar.status_codes import HTTP_409_CONFLICT
15from lilbee.app.services import get_services
16from lilbee.server.models import (
17 SessionCreateRequest,
18 SessionDeleteResponse,
19 SessionDetailResponse,
20 SessionListResponse,
21 SessionMessageCreateRequest,
22 SessionMessageItem,
23 SessionMetaItem,
24 SessionRenameResponse,
25 SessionSummaryRequest,
26)
27from lilbee.sessions import (
28 HUMAN_ORIGINS,
29 SESSIONS_DISABLED_HINT,
30 Session,
31 SessionMessage,
32 SessionMeta,
33 SessionNotFoundError,
34 SessionOrigin,
35 SessionOwnershipError,
36 SessionStore,
37 TitleSource,
38 sessions_enabled,
39)
42def _require_sessions() -> None:
43 """Raise 404 if session persistence is disabled (on by default)."""
44 if not sessions_enabled():
45 raise NotFoundException(detail=SESSIONS_DISABLED_HINT)
48def _store() -> SessionStore:
49 _require_sessions()
50 return get_services().session_store
53@contextmanager
54def _session_errors() -> Generator[None, None, None]:
55 """Map the store's typed failures onto the statuses the handlers document.
57 Wraps the *whole* handler body, not just the mutation. Each of these
58 handlers mutates and then re-reads the session to build its response, and
59 the TUI and HTTP surfaces share one store: a session deleted between the
60 two calls made the trailing read raise an unguarded SessionNotFoundError
61 that escaped as a 500 instead of the promised 404.
62 """
63 try:
64 yield
65 except SessionNotFoundError as exc:
66 raise NotFoundException(detail=str(exc)) from exc
67 except SessionOwnershipError as exc:
68 # 409, not 403: the resource exists and the token is fine; the session
69 # is owned elsewhere, and claiming it is the documented resolution.
70 raise ClientException(detail=str(exc), status_code=HTTP_409_CONFLICT) from exc
73def _meta_item(meta: SessionMeta) -> SessionMetaItem:
74 return SessionMetaItem(
75 id=meta.id,
76 title=meta.title,
77 created_at=meta.created_at,
78 updated_at=meta.updated_at,
79 model_ref=meta.model_ref,
80 scope=meta.scope,
81 message_count=meta.message_count,
82 origin=meta.origin.value,
83 )
86def _detail(session: Session) -> SessionDetailResponse:
87 return SessionDetailResponse(
88 meta=_meta_item(session.meta),
89 messages=[
90 SessionMessageItem(
91 role=message.role,
92 content=message.content,
93 sources=list(message.sources),
94 ts=message.ts,
95 )
96 for message in session.messages
97 ],
98 summary=session.summary,
99 )
102async def list_sessions() -> SessionListResponse:
103 """Return every session's metadata, newest first."""
104 return SessionListResponse(
105 sessions=[_meta_item(meta) for meta in _store().list(origins=HUMAN_ORIGINS)]
106 )
109async def get_session(session_id: str) -> SessionDetailResponse:
110 """Return a session's metadata and transcript, or 404 if unknown."""
111 with _session_errors():
112 return _detail(_store().get(session_id))
115async def create_session(data: SessionCreateRequest) -> SessionDetailResponse:
116 """Start a new conversation and return it (empty transcript, no summary)."""
117 store = _store()
118 with _session_errors():
119 session_id = store.create(
120 model_ref=data.model_ref, scope=data.scope, origin=SessionOrigin.HTTP
121 )
122 return _detail(store.get(session_id))
125async def add_session_message(
126 session_id: str, data: SessionMessageCreateRequest
127) -> SessionDetailResponse:
128 """Append one turn to a conversation and return it, or 404 if unknown."""
129 message = SessionMessage(role=data.role, content=data.content, sources=tuple(data.sources))
130 store = _store()
131 with _session_errors():
132 store.add_message(session_id, message, surface=SessionOrigin.HTTP)
133 return _detail(store.get(session_id))
136async def claim_session(session_id: str) -> SessionDetailResponse:
137 """Claim a conversation for the HTTP surface, or 404 if unknown."""
138 store = _store()
139 with _session_errors():
140 store.transfer(session_id, SessionOrigin.HTTP)
141 return _detail(store.get(session_id))
144async def set_session_summary(
145 session_id: str, data: SessionSummaryRequest
146) -> SessionDetailResponse:
147 """Replace a conversation's compaction summary, or 404 if unknown."""
148 store = _store()
149 with _session_errors():
150 store.set_summary(session_id, data.summary)
151 return _detail(store.get(session_id))
154async def rename_session(session_id: str, title: str) -> SessionRenameResponse:
155 """Rename a session, or 404 if unknown."""
156 with _session_errors():
157 _store().set_title(session_id, title, TitleSource.CUSTOM)
158 return SessionRenameResponse(id=session_id, title=title)
161async def delete_session(session_id: str) -> SessionDeleteResponse:
162 """Delete a session, or 404 if unknown."""
163 with _session_errors():
164 _store().delete(session_id)
165 return SessionDeleteResponse(id=session_id, deleted=True)