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

1"""Session route handlers: list, get, 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 

11 

12from litestar.exceptions import ClientException, NotFoundException 

13from litestar.status_codes import HTTP_409_CONFLICT 

14 

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) 

40 

41 

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) 

46 

47 

48def _store() -> SessionStore: 

49 _require_sessions() 

50 return get_services().session_store 

51 

52 

53@contextmanager 

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

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

56 

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 

71 

72 

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 ) 

84 

85 

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 ) 

100 

101 

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 ) 

107 

108 

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

113 

114 

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

123 

124 

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

134 

135 

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

142 

143 

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

152 

153 

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) 

159 

160 

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)