use async_trait::async_trait;
use crate::{ReviewRequest, Session, SessionError, SessionId};
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum CreateSessionMode {
#[default]
Regular,
Draft,
Stacked {
parent_session_id: SessionId,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CreateSessionRequest {
pub mode: CreateSessionMode,
pub project_id: i64,
}
#[async_trait]
pub trait SessionBackend: Send {
async fn create_session(
&mut self,
request: CreateSessionRequest,
) -> Result<SessionId, SessionError>;
async fn get_session(&self, session_id: &SessionId) -> Result<Option<Session>, SessionError>;
async fn send_message(
&mut self,
session_id: &SessionId,
message: String,
) -> Result<(), SessionError>;
async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError>;
async fn create_review_request(
&mut self,
session_id: &SessionId,
) -> Result<ReviewRequest, SessionError>;
}
pub struct SessionService<'backend> {
backend: &'backend mut dyn SessionBackend,
}
impl<'backend> SessionService<'backend> {
pub fn new(backend: &'backend mut dyn SessionBackend) -> Self {
Self { backend }
}
pub async fn create_session(
&mut self,
request: CreateSessionRequest,
) -> Result<SessionId, SessionError> {
self.backend.create_session(request).await
}
pub async fn get_session(
&self,
session_id: &SessionId,
) -> Result<Option<Session>, SessionError> {
self.backend.get_session(session_id).await
}
pub async fn send_message(
&mut self,
session_id: &SessionId,
message: impl Into<String> + Send,
) -> Result<(), SessionError> {
self.backend.send_message(session_id, message.into()).await
}
pub async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError> {
self.backend.merge_session(session_id).await
}
pub async fn create_review_request(
&mut self,
session_id: &SessionId,
) -> Result<ReviewRequest, SessionError> {
self.backend.create_review_request(session_id).await
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use ag_agent::{AgentKind, AgentModel, AgentSelection, ReasoningLevel};
use ag_forge::{ForgeKind, ReviewRequestState, ReviewRequestSummary};
use super::*;
use crate::{SessionMessage, SessionMessageKind, SessionSettings, SessionStatus};
#[derive(Default)]
struct FakeBackend {
calls: Vec<String>,
create_results: VecDeque<Result<SessionId, SessionError>>,
get_result: Option<Result<Option<Session>, SessionError>>,
review_result: Option<Result<ReviewRequest, SessionError>>,
unit_results: VecDeque<Result<(), SessionError>>,
}
#[async_trait]
impl SessionBackend for FakeBackend {
async fn create_session(
&mut self,
request: CreateSessionRequest,
) -> Result<SessionId, SessionError> {
self.calls.push(format!("create:{:?}", request.mode));
self.create_results
.pop_front()
.unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
}
async fn get_session(
&self,
_session_id: &SessionId,
) -> Result<Option<Session>, SessionError> {
self.get_result
.clone()
.unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
}
async fn send_message(
&mut self,
session_id: &SessionId,
message: String,
) -> Result<(), SessionError> {
self.calls.push(format!("send:{session_id}:{message}"));
self.unit_results
.pop_front()
.unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
}
async fn merge_session(&mut self, session_id: &SessionId) -> Result<(), SessionError> {
self.calls.push(format!("merge:{session_id}"));
self.unit_results
.pop_front()
.unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
}
async fn create_review_request(
&mut self,
session_id: &SessionId,
) -> Result<ReviewRequest, SessionError> {
self.calls.push(format!("review:{session_id}"));
self.review_result
.clone()
.unwrap_or_else(|| Err(SessionError::Operation("missing result".to_string())))
}
}
fn session_fixture() -> Session {
Session {
created_at: 10,
draft_prompt: None,
id: SessionId::from("session-1"),
messages: vec![SessionMessage::new(
0,
SessionMessageKind::UserPrompt,
"build it",
)],
published_upstream_ref: None,
questions: Vec::new(),
queued_messages: Vec::new(),
review_request: None,
settings: SessionSettings {
agent: AgentSelection::new(AgentKind::Codex, AgentModel::Gpt56Sol),
base_branch: "main".to_string(),
is_draft: false,
parent_session_id: None,
personality_id: Some("reviewer".to_string()),
project_id: 7,
reasoning_level: ReasoningLevel::High,
},
status: SessionStatus::Review,
summary: Some("Implemented it".to_string()),
title: Some("Build it".to_string()),
updated_at: 20,
}
}
fn review_request_fixture() -> ReviewRequest {
ReviewRequest {
last_refreshed_at: 30,
summary: ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "wt/session-1".to_string(),
state: ReviewRequestState::Open,
status_summary: None,
target_branch: "main".to_string(),
title: "Build it".to_string(),
web_url: "https://example.test/pull/42".to_string(),
},
}
}
#[tokio::test]
async fn service_delegates_create_and_get() {
let expected_session = session_fixture();
let mut backend = FakeBackend {
create_results: VecDeque::from([Ok(SessionId::from("session-1"))]),
get_result: Some(Ok(Some(expected_session.clone()))),
..FakeBackend::default()
};
let loaded_session = {
let mut service = SessionService::new(&mut backend);
let session_id = service
.create_session(CreateSessionRequest {
mode: CreateSessionMode::Regular,
project_id: 7,
})
.await
.expect("session should be created");
service
.get_session(&session_id)
.await
.expect("session should load")
};
assert_eq!(loaded_session, Some(expected_session));
assert_eq!(backend.calls, ["create:Regular"]);
}
#[tokio::test]
async fn service_delegates_send_merge_and_review() {
let expected_review_request = review_request_fixture();
let mut backend = FakeBackend {
review_result: Some(Ok(expected_review_request.clone())),
unit_results: VecDeque::from([Ok(()), Ok(())]),
..FakeBackend::default()
};
let session_id = SessionId::from("session-1");
let review_request = {
let mut service = SessionService::new(&mut backend);
service
.send_message(&session_id, "continue")
.await
.expect("message should be sent");
service
.merge_session(&session_id)
.await
.expect("merge should be requested");
service
.create_review_request(&session_id)
.await
.expect("review request should be created")
};
assert_eq!(review_request, expected_review_request);
assert_eq!(
backend.calls,
[
"send:session-1:continue",
"merge:session-1",
"review:session-1"
]
);
}
#[tokio::test]
async fn service_preserves_backend_errors() {
let expected_error = SessionError::Operation("cannot create".to_string());
let mut backend = FakeBackend {
create_results: VecDeque::from([Err(expected_error.clone())]),
..FakeBackend::default()
};
let mut service = SessionService::new(&mut backend);
let error = service
.create_session(CreateSessionRequest {
mode: CreateSessionMode::Draft,
project_id: 7,
})
.await
.expect_err("backend error should be preserved");
assert_eq!(error, expected_error);
}
#[tokio::test]
async fn fake_backend_requires_explicit_results() {
let mut backend = FakeBackend::default();
let session_id = SessionId::from("session-1");
let errors = {
let mut service = SessionService::new(&mut backend);
let create_error = service
.create_session(CreateSessionRequest {
mode: CreateSessionMode::Regular,
project_id: 7,
})
.await
.expect_err("create should require a result");
let get_error = service
.get_session(&session_id)
.await
.expect_err("get should require a result");
let send_error = service
.send_message(&session_id, "continue")
.await
.expect_err("send should require a result");
let merge_error = service
.merge_session(&session_id)
.await
.expect_err("merge should require a result");
let review_error = service
.create_review_request(&session_id)
.await
.expect_err("review should require a result");
[
create_error,
get_error,
send_error,
merge_error,
review_error,
]
};
assert!(
errors
.into_iter()
.all(|error| error == SessionError::Operation("missing result".to_string()))
);
}
}