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