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

1"""Session route handlers: list, get, markdown, create, append, fork, rename, forget. 

2 

3Reads and mutations go through the process ``SessionStore`` on the services 

4container. A missing session id surfaces as a 404. 

5""" 

6 

7from __future__ import annotations 

8 

9from collections.abc import Generator 

10from contextlib import contextmanager 

11from dataclasses import dataclass 

12 

13from litestar.exceptions import ClientException, NotFoundException 

14from litestar.status_codes import HTTP_409_CONFLICT, HTTP_422_UNPROCESSABLE_ENTITY 

15 

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) 

45 

46 

47@dataclass(frozen=True) 

48class SessionMarkdown: 

49 """A session's markdown export and the file name it is saved under.""" 

50 

51 filename: str 

52 markdown: str 

53 

54 

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) 

59 

60 

61def _store() -> SessionStore: 

62 _require_sessions() 

63 return get_services().session_store 

64 

65 

66@contextmanager 

67def _session_errors() -> Generator[None, None, None]: 

68 """Map the store's typed failures onto the statuses the handlers document. 

69 

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 

86 

87 

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 ) 

100 

101 

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 ) 

108 

109 

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 ) 

115 

116 

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)) 

121 

122 

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 ) 

130 

131 

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)) 

140 

141 

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)) 

163 

164 

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)) 

172 

173 

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)) 

180 

181 

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)) 

190 

191 

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) 

197 

198 

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)