Coverage for src/lilbee/server/handlers/sessions.py: 100%
83 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Session route handlers: list, get, markdown, create, append, fork, 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
11from dataclasses import dataclass
13from litestar.exceptions import ClientException, NotFoundException
14from litestar.status_codes import HTTP_409_CONFLICT, HTTP_422_UNPROCESSABLE_ENTITY
16from lilbee.app.services import get_services
17from lilbee.app.session_export import default_export_name, session_markdown
18from lilbee.app.session_sources import LocatorError, message_payload, posted_message
19from lilbee.server.models import (
20 SessionCreateRequest,
21 SessionDeleteResponse,
22 SessionDetailResponse,
23 SessionForkRequest,
24 SessionListResponse,
25 SessionMessageCreateRequest,
26 SessionMessageItem,
27 SessionMetaItem,
28 SessionRenameResponse,
29 SessionSummaryRequest,
30)
31from lilbee.sessions import (
32 HUMAN_ORIGINS,
33 SESSIONS_DISABLED_HINT,
34 Session,
35 SessionForkRangeError,
36 SessionMeta,
37 SessionNotFoundError,
38 SessionOrigin,
39 SessionOwnershipError,
40 SessionStore,
41 StoredSourceRef,
42 TitleSource,
43 sessions_enabled,
44)
47@dataclass(frozen=True)
48class SessionMarkdown:
49 """A session's markdown export and the file name it is saved under."""
51 filename: str
52 markdown: str
55def _require_sessions() -> None:
56 """Raise 404 if session persistence is disabled (on by default)."""
57 if not sessions_enabled():
58 raise NotFoundException(detail=SESSIONS_DISABLED_HINT)
61def _store() -> SessionStore:
62 _require_sessions()
63 return get_services().session_store
66@contextmanager
67def _session_errors() -> Generator[None, None, None]:
68 """Map the store's typed failures onto the statuses the handlers document.
70 Wraps the *whole* handler body, not just the mutation. Each of these
71 handlers mutates and then re-reads the session to build its response, and
72 the TUI and HTTP surfaces share one store: a session deleted between the
73 two calls made the trailing read raise an unguarded SessionNotFoundError
74 that escaped as a 500 instead of the promised 404.
75 """
76 try:
77 yield
78 except SessionNotFoundError as exc:
79 raise NotFoundException(detail=str(exc)) from exc
80 except SessionOwnershipError as exc:
81 # 409, not 403: the resource exists and the token is fine; the session
82 # is owned elsewhere, and claiming it is the documented resolution.
83 raise ClientException(detail=str(exc), status_code=HTTP_409_CONFLICT) from exc
84 except SessionForkRangeError as exc:
85 raise ClientException(detail=str(exc), status_code=HTTP_422_UNPROCESSABLE_ENTITY) from exc
88def _meta_item(meta: SessionMeta) -> SessionMetaItem:
89 return SessionMetaItem(
90 id=meta.id,
91 title=meta.title,
92 created_at=meta.created_at,
93 updated_at=meta.updated_at,
94 model_ref=meta.model_ref,
95 scope=meta.scope,
96 message_count=meta.message_count,
97 origin=meta.origin.value,
98 forked_from=meta.forked_from,
99 )
102def _detail(session: Session) -> SessionDetailResponse:
103 return SessionDetailResponse(
104 meta=_meta_item(session.meta),
105 messages=[SessionMessageItem(**message_payload(message)) for message in session.messages],
106 summary=session.summary,
107 )
110async def list_sessions() -> SessionListResponse:
111 """Return every session's metadata, newest first."""
112 return SessionListResponse(
113 sessions=[_meta_item(meta) for meta in _store().list(origins=HUMAN_ORIGINS)]
114 )
117async def get_session(session_id: str) -> SessionDetailResponse:
118 """Return a session's metadata and transcript, or 404 if unknown."""
119 with _session_errors():
120 return _detail(_store().get(session_id))
123async def get_session_markdown(session_id: str) -> SessionMarkdown:
124 """Return a session as a markdown document with its file name, or 404 if unknown."""
125 with _session_errors():
126 session = _store().get(session_id)
127 return SessionMarkdown(
128 filename=default_export_name(session.meta), markdown=session_markdown(session)
129 )
132async def create_session(data: SessionCreateRequest) -> SessionDetailResponse:
133 """Start a new conversation and return it (empty transcript, no summary)."""
134 store = _store()
135 with _session_errors():
136 session_id = store.create(
137 model_ref=data.model_ref, scope=data.scope, origin=SessionOrigin.HTTP
138 )
139 return _detail(store.get(session_id))
142async def add_session_message(
143 session_id: str, data: SessionMessageCreateRequest
144) -> SessionDetailResponse:
145 """Append one turn to a conversation and return it, 404 if unknown, 422 for a
146 locator lilbee never writes."""
147 refs = data.source_refs
148 try:
149 message = posted_message(
150 data.role,
151 data.content,
152 data.sources,
153 None
154 if refs is None
155 else [StoredSourceRef(r.source, r.citation_number, r.locator) for r in refs],
156 )
157 except LocatorError as exc:
158 raise ClientException(detail=str(exc), status_code=HTTP_422_UNPROCESSABLE_ENTITY) from exc
159 store = _store()
160 with _session_errors():
161 store.add_message(session_id, message, surface=SessionOrigin.HTTP)
162 return _detail(store.get(session_id))
165async def fork_session(session_id: str, data: SessionForkRequest | None) -> SessionDetailResponse:
166 """Copy a conversation's leading messages into a new one and return it."""
167 message_count = data.message_count if data is not None else None
168 store = _store()
169 with _session_errors():
170 fork_id = store.fork(session_id, message_count=message_count, origin=SessionOrigin.HTTP)
171 return _detail(store.get(fork_id))
174async def claim_session(session_id: str) -> SessionDetailResponse:
175 """Claim a conversation for the HTTP surface, or 404 if unknown."""
176 store = _store()
177 with _session_errors():
178 store.transfer(session_id, SessionOrigin.HTTP)
179 return _detail(store.get(session_id))
182async def set_session_summary(
183 session_id: str, data: SessionSummaryRequest
184) -> SessionDetailResponse:
185 """Replace a conversation's compaction summary, or 404 if unknown."""
186 store = _store()
187 with _session_errors():
188 store.set_summary(session_id, data.summary)
189 return _detail(store.get(session_id))
192async def rename_session(session_id: str, title: str) -> SessionRenameResponse:
193 """Rename a session, or 404 if unknown."""
194 with _session_errors():
195 _store().set_title(session_id, title, TitleSource.CUSTOM)
196 return SessionRenameResponse(id=session_id, title=title)
199async def delete_session(session_id: str) -> SessionDeleteResponse:
200 """Delete a session, or 404 if unknown."""
201 with _session_errors():
202 _store().delete(session_id)
203 return SessionDeleteResponse(id=session_id, deleted=True)