1use async_trait::async_trait;
4
5use crate::{ReviewRequest, Session, SessionError, SessionId};
6
7#[derive(Clone, Debug, Default, Eq, PartialEq)]
9pub enum CreateSessionMode {
10 #[default]
12 Regular,
13 Draft,
15 Stacked {
17 parent_session_id: SessionId,
19 },
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct CreateSessionRequest {
25 pub mode: CreateSessionMode,
27 pub project_id: i64,
29}
30
31#[async_trait]
36pub trait SessionBackend: Send {
37 async fn create_session(
39 &mut self,
40 request: CreateSessionRequest,
41 ) -> Result<SessionId, SessionError>;
42
43 async fn get_session(&self, session_id: &SessionId) -> Result<Option<Session>, SessionError>;
45
46 async fn send_message(
48 &mut self,
49 session_id: &SessionId,
50 message: String,
51 ) -> Result<(), SessionError>;
52
53 async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError>;
55
56 async fn create_review_request(
59 &mut self,
60 session_id: &SessionId,
61 ) -> Result<ReviewRequest, SessionError>;
62}
63
64pub struct SessionService<'backend> {
66 backend: &'backend mut dyn SessionBackend,
67}
68
69impl<'backend> SessionService<'backend> {
70 pub fn new(backend: &'backend mut dyn SessionBackend) -> Self {
72 Self { backend }
73 }
74
75 pub async fn create_session(
80 &mut self,
81 request: CreateSessionRequest,
82 ) -> Result<SessionId, SessionError> {
83 self.backend.create_session(request).await
84 }
85
86 pub async fn get_session(
91 &self,
92 session_id: &SessionId,
93 ) -> Result<Option<Session>, SessionError> {
94 self.backend.get_session(session_id).await
95 }
96
97 pub async fn send_message(
102 &mut self,
103 session_id: &SessionId,
104 message: impl Into<String> + Send,
105 ) -> Result<(), SessionError> {
106 self.backend.send_message(session_id, message.into()).await
107 }
108
109 pub async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError> {
114 self.backend.merge_session(session_id).await
115 }
116
117 pub async fn create_review_request(
123 &mut self,
124 session_id: &SessionId,
125 ) -> Result<ReviewRequest, SessionError> {
126 self.backend.create_review_request(session_id).await
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use std::collections::VecDeque;
133
134 use ag_agent::{AgentKind, AgentModel, AgentSelection, ReasoningLevel};
135 use ag_forge::{ForgeKind, ReviewRequestState, ReviewRequestSummary};
136
137 use super::*;
138 use crate::{SessionMessage, SessionMessageKind, SessionSettings, SessionStatus};
139
140 #[derive(Default)]
141 struct FakeBackend {
142 calls: Vec<String>,
143 create_results: VecDeque<Result<SessionId, SessionError>>,
144 get_result: Option<Result<Option<Session>, SessionError>>,
145 review_result: Option<Result<ReviewRequest, SessionError>>,
146 unit_results: VecDeque<Result<(), SessionError>>,
147 }
148
149 #[async_trait]
150 impl SessionBackend for FakeBackend {
151 async fn create_session(
152 &mut self,
153 request: CreateSessionRequest,
154 ) -> Result<SessionId, SessionError> {
155 self.calls.push(format!("create:{:?}", request.mode));
156
157 self.create_results
158 .pop_front()
159 .unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
160 }
161
162 async fn get_session(
163 &self,
164 _session_id: &SessionId,
165 ) -> Result<Option<Session>, SessionError> {
166 self.get_result
167 .clone()
168 .unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
169 }
170
171 async fn send_message(
172 &mut self,
173 session_id: &SessionId,
174 message: String,
175 ) -> Result<(), SessionError> {
176 self.calls.push(format!("send:{session_id}:{message}"));
177
178 self.unit_results
179 .pop_front()
180 .unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
181 }
182
183 async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError> {
184 self.calls.push(format!("merge:{session_id}"));
185
186 self.unit_results
187 .pop_front()
188 .unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
189 }
190
191 async fn create_review_request(
192 &mut self,
193 session_id: &SessionId,
194 ) -> Result<ReviewRequest, SessionError> {
195 self.calls.push(format!("review:{session_id}"));
196
197 self.review_result
198 .clone()
199 .unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
200 }
201 }
202
203 fn session_fixture() -> Session {
204 Session {
205 created_at: 10,
206 draft_prompt: None,
207 id: SessionId::from("session-1"),
208 messages: vec![SessionMessage::new(
209 0,
210 SessionMessageKind::UserPrompt,
211 "build it",
212 )],
213 published_upstream_ref: None,
214 questions: Vec::new(),
215 queued_messages: Vec::new(),
216 review_request: None,
217 settings: SessionSettings {
218 agent: AgentSelection::new(AgentKind::Codex, AgentModel::Gpt56Sol),
219 base_branch: "main".to_string(),
220 is_draft: false,
221 parent_session_id: None,
222 personality_id: Some("reviewer".to_string()),
223 project_id: 7,
224 reasoning_level: ReasoningLevel::High,
225 },
226 status: SessionStatus::Review,
227 summary: Some("Implemented it".to_string()),
228 title: Some("Build it".to_string()),
229 updated_at: 20,
230 }
231 }
232
233 fn review_request_fixture() -> ReviewRequest {
234 ReviewRequest {
235 last_refreshed_at: 30,
236 summary: ReviewRequestSummary {
237 display_id: "#42".to_string(),
238 forge_kind: ForgeKind::GitHub,
239 source_branch: "wt/session-1".to_string(),
240 state: ReviewRequestState::Open,
241 status_summary: None,
242 target_branch: "main".to_string(),
243 title: "Build it".to_string(),
244 web_url: "https://example.test/pull/42".to_string(),
245 },
246 }
247 }
248
249 #[tokio::test]
250 async fn service_delegates_create_and_get() {
251 let expected_session = session_fixture();
253 let mut backend = FakeBackend {
254 create_results: VecDeque::from([Ok(SessionId::from("session-1"))]),
255 get_result: Some(Ok(Some(expected_session.clone()))),
256 ..FakeBackend::default()
257 };
258 let loaded_session = {
260 let mut service = SessionService::new(&mut backend);
261 let session_id = service
262 .create_session(CreateSessionRequest {
263 mode: CreateSessionMode::Regular,
264 project_id: 7,
265 })
266 .await
267 .expect("session should be created");
268
269 service
270 .get_session(&session_id)
271 .await
272 .expect("session should load")
273 };
274
275 assert_eq!(loaded_session, Some(expected_session));
277 assert_eq!(backend.calls, ["create:Regular"]);
278 }
279
280 #[tokio::test]
281 async fn service_delegates_send_merge_and_review() {
282 let expected_review_request = review_request_fixture();
284 let mut backend = FakeBackend {
285 review_result: Some(Ok(expected_review_request.clone())),
286 unit_results: VecDeque::from([Ok(()), Ok(())]),
287 ..FakeBackend::default()
288 };
289 let session_id = SessionId::from("session-1");
290 let review_request = {
292 let mut service = SessionService::new(&mut backend);
293 service
294 .send_message(&session_id, "continue")
295 .await
296 .expect("message should be sent");
297 service
298 .merge_session(&session_id)
299 .await
300 .expect("merge should be requested");
301
302 service
303 .create_review_request(&session_id)
304 .await
305 .expect("review request should be created")
306 };
307
308 assert_eq!(review_request, expected_review_request);
310 assert_eq!(
311 backend.calls,
312 [
313 "send:session-1:continue",
314 "merge:session-1",
315 "review:session-1"
316 ]
317 );
318 }
319
320 #[tokio::test]
321 async fn service_preserves_backend_errors() {
322 let expected_error = SessionError::Operation("cannot create".to_string());
324 let mut backend = FakeBackend {
325 create_results: VecDeque::from([Err(expected_error.clone())]),
326 ..FakeBackend::default()
327 };
328 let mut service = SessionService::new(&mut backend);
329
330 let error = service
332 .create_session(CreateSessionRequest {
333 mode: CreateSessionMode::Draft,
334 project_id: 7,
335 })
336 .await
337 .expect_err("backend error should be preserved");
338
339 assert_eq!(error, expected_error);
341 }
342
343 #[tokio::test]
344 async fn fake_backend_requires_explicit_results() {
345 let mut backend = FakeBackend::default();
347 let session_id = SessionId::from("session-1");
348
349 let errors = {
351 let mut service = SessionService::new(&mut backend);
352 let create_error = service
353 .create_session(CreateSessionRequest {
354 mode: CreateSessionMode::Regular,
355 project_id: 7,
356 })
357 .await
358 .expect_err("create should require a result");
359 let get_error = service
360 .get_session(&session_id)
361 .await
362 .expect_err("get should require a result");
363 let send_error = service
364 .send_message(&session_id, "continue")
365 .await
366 .expect_err("send should require a result");
367 let merge_error = service
368 .merge_session(&session_id)
369 .await
370 .expect_err("merge should require a result");
371 let review_error = service
372 .create_review_request(&session_id)
373 .await
374 .expect_err("review should require a result");
375
376 [
377 create_error,
378 get_error,
379 send_error,
380 merge_error,
381 review_error,
382 ]
383 };
384
385 assert!(
387 errors
388 .into_iter()
389 .all(|error| error == SessionError::Operation("missing result".to_string()))
390 );
391 }
392}