Skip to main content

ag_session/
service.rs

1//! Programmatic session orchestration facade and host backend port.
2
3use async_trait::async_trait;
4
5use crate::{ReviewRequest, Session, SessionError, SessionId};
6
7/// Creation strategy for a new session.
8#[derive(Clone, Debug, Default, Eq, PartialEq)]
9pub enum CreateSessionMode {
10    /// Creates a regular root session with an eagerly materialized worktree.
11    #[default]
12    Regular,
13    /// Creates a root draft whose worktree is materialized on first send.
14    Draft,
15    /// Creates a one-level draft stacked on an existing parent session.
16    Stacked {
17        /// Review-ready parent session whose branch becomes the stack base.
18        parent_session_id: SessionId,
19    },
20}
21
22/// Explicit input for creating one session.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct CreateSessionRequest {
25    /// Determines whether the session is regular, deferred, or stacked.
26    pub mode: CreateSessionMode,
27    /// Project that owns the new session.
28    pub project_id: i64,
29}
30
31/// Host implementation boundary for session persistence and workflows.
32///
33/// The trait is object-safe so agent loops and future orchestrators can hold a
34/// programmatic session capability without depending on a concrete frontend.
35#[async_trait]
36pub trait SessionBackend: Send {
37    /// Creates one session and returns its stable identifier.
38    async fn create_session(
39        &mut self,
40        request: CreateSessionRequest,
41    ) -> Result<SessionId, SessionError>;
42
43    /// Loads one complete session aggregate, including settings and messages.
44    async fn get_session(&self, session_id: &SessionId) -> Result<Option<Session>, SessionError>;
45
46    /// Sends one text message, starting, resuming, or queueing as appropriate.
47    async fn send_message(
48        &mut self,
49        session_id: &SessionId,
50        message: String,
51    ) -> Result<(), SessionError>;
52
53    /// Requests merge processing for one review-ready session.
54    async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError>;
55
56    /// Publishes one session branch and creates or refreshes its review
57    /// request.
58    async fn create_review_request(
59        &mut self,
60        session_id: &SessionId,
61    ) -> Result<ReviewRequest, SessionError>;
62}
63
64/// Stable programmatic facade for session lifecycle operations.
65pub struct SessionService<'backend> {
66    backend: &'backend mut dyn SessionBackend,
67}
68
69impl<'backend> SessionService<'backend> {
70    /// Binds the stable session API to one host backend.
71    pub fn new(backend: &'backend mut dyn SessionBackend) -> Self {
72        Self { backend }
73    }
74
75    /// Creates one session and returns its stable identifier.
76    ///
77    /// # Errors
78    /// Returns an error when the host cannot create the requested session.
79    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    /// Loads one complete session aggregate by identifier.
87    ///
88    /// # Errors
89    /// Returns an error when persisted data cannot be loaded or decoded.
90    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    /// Sends a text message to one session.
98    ///
99    /// # Errors
100    /// Returns an error when the session cannot accept or enqueue the message.
101    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    /// Requests merge processing for one session.
110    ///
111    /// # Errors
112    /// Returns an error when the session is not mergeable or queueing fails.
113    pub async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError> {
114        self.backend.merge_session(session_id).await
115    }
116
117    /// Publishes one session and creates or refreshes its review request.
118    ///
119    /// # Errors
120    /// Returns an error when branch publication, forge access, or persistence
121    /// fails.
122    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        // Arrange
252        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        // Act
259        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
276        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        // Arrange
283        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        // Act
291        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
309        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        // Arrange
323        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        // Act
331        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
340        assert_eq!(error, expected_error);
341    }
342
343    #[tokio::test]
344    async fn fake_backend_requires_explicit_results() {
345        // Arrange
346        let mut backend = FakeBackend::default();
347        let session_id = SessionId::from("session-1");
348
349        // Act
350        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
386        assert!(
387            errors
388                .into_iter()
389                .all(|error| error == SessionError::Operation("missing result".to_string()))
390        );
391    }
392}