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

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

44 

45 

46@dataclass(frozen=True) 

47class SessionMarkdown: 

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

49 

50 filename: str 

51 markdown: str 

52 

53 

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) 

58 

59 

60def _store() -> SessionStore: 

61 _require_sessions() 

62 return get_services().session_store 

63 

64 

65@contextmanager 

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

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

68 

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 

85 

86 

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 ) 

99 

100 

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 ) 

115 

116 

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 ) 

122 

123 

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

128 

129 

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 ) 

137 

138 

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

147 

148 

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

158 

159 

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

167 

168 

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

175 

176 

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

185 

186 

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) 

192 

193 

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)