use std::{borrow::Cow, cmp::Ordering};
use agent_client_protocol::{
schema::v1::{
DeleteSessionResponse, ListSessionsRequest, ListSessionsResponse, SessionId, SessionInfo,
},
Error as AcpError,
};
use crate::session::{DeleteOptions, Session as StoredSession, SessionSummary, SessionTarget};
const PAGE_SIZE: usize = 100;
const FALLBACK_TITLE_CHARS: usize = 80;
pub(super) async fn list(request: ListSessionsRequest) -> Result<ListSessionsResponse, AcpError> {
off_runtime(move || {
let summaries = match &request.cwd {
Some(cwd) if !cwd.is_absolute() => {
return Err(AcpError::invalid_params().data("cwd must be an absolute path"));
}
Some(cwd) if !cwd.is_dir() => Vec::new(),
Some(cwd) => StoredSession::list(cwd).map_err(store_error)?,
None => StoredSession::list_all().map_err(store_error)?,
};
page(summaries, request.cursor.as_deref())
})
.await
}
pub(super) async fn delete_target(session_id: SessionId) -> Result<SessionTarget, AcpError> {
off_runtime(move || Ok(exact_session(session_id.0.as_ref())?.target())).await
}
pub(super) async fn delete(target: SessionTarget) -> Result<DeleteSessionResponse, AcpError> {
off_runtime(move || {
StoredSession::delete_target(&target, DeleteOptions::default()).map_err(store_error)?;
Ok(DeleteSessionResponse::new())
})
.await
}
fn exact_session(id: &str) -> Result<SessionSummary, AcpError> {
let mut matches = StoredSession::list_all()
.map_err(store_error)?
.into_iter()
.filter(|summary| summary.id == id);
match (matches.next(), matches.next()) {
(Some(summary), None) => Ok(summary),
(None, _) => Err(AcpError::resource_not_found(Some(id.to_owned()))),
(Some(_), Some(_)) => Err(AcpError::invalid_request().data(format!(
"session '{id}' is saved in more than one workspace"
))),
}
}
fn page(
mut summaries: Vec<SessionSummary>,
cursor: Option<&str>,
) -> Result<ListSessionsResponse, AcpError> {
let after = cursor.map(Cursor::parse).transpose()?;
summaries.retain(|summary| {
summary.has_messages()
&& summary.cwd.is_dir()
&& after.as_ref().is_none_or(|after| after.precedes(summary))
});
summaries.sort_by(|left, right| Cursor::at(left).cmp_list_order(&Cursor::at(right)));
let next_cursor = summaries
.get(PAGE_SIZE)
.map(|_| Cursor::at(&summaries[PAGE_SIZE - 1]).encode());
summaries.truncate(PAGE_SIZE);
Ok(
ListSessionsResponse::new(summaries.into_iter().map(session_info).collect())
.next_cursor(next_cursor),
)
}
#[derive(Debug, PartialEq, Eq)]
struct Cursor<'a> {
updated_at: u64,
id: Cow<'a, str>,
cwd: Cow<'a, str>,
}
impl<'a> Cursor<'a> {
fn at(summary: &'a SessionSummary) -> Self {
Self {
updated_at: summary.updated_at,
id: Cow::Borrowed(&summary.id),
cwd: summary.cwd.to_string_lossy(),
}
}
fn encode(&self) -> String {
serde_json::json!([self.updated_at, self.id, self.cwd]).to_string()
}
fn parse(cursor: &str) -> Result<Cursor<'static>, AcpError> {
serde_json::from_str::<(u64, String, String)>(cursor)
.map(|(updated_at, id, cwd)| Cursor {
updated_at,
id: Cow::Owned(id),
cwd: Cow::Owned(cwd),
})
.map_err(|_| {
AcpError::invalid_params().data(format!("unknown session/list cursor '{cursor}'"))
})
}
fn cmp_list_order(&self, other: &Cursor<'_>) -> Ordering {
other
.updated_at
.cmp(&self.updated_at)
.then_with(|| self.id.cmp(&other.id))
.then_with(|| self.cwd.cmp(&other.cwd))
}
fn precedes(&self, summary: &SessionSummary) -> bool {
self.cmp_list_order(&Cursor::at(summary)) == Ordering::Less
}
}
fn session_info(summary: SessionSummary) -> SessionInfo {
let title = summary.title.or_else(|| {
summary
.first_user_message
.as_deref()
.and_then(fallback_title)
});
let updated_at = i64::try_from(summary.updated_at)
.ok()
.and_then(|secs| chrono::DateTime::from_timestamp(secs, 0))
.map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
SessionInfo::new(SessionId::new(summary.id), summary.cwd)
.title(title)
.updated_at(updated_at)
}
fn fallback_title(message: &str) -> Option<String> {
let line = message.split_whitespace().collect::<Vec<_>>().join(" ");
if line.is_empty() {
return None;
}
if line.chars().count() <= FALLBACK_TITLE_CHARS {
return Some(line);
}
let mut cut = line
.chars()
.take(FALLBACK_TITLE_CHARS - 1)
.collect::<String>();
cut.push('…');
Some(cut)
}
async fn off_runtime<T: Send + 'static>(
work: impl FnOnce() -> Result<T, AcpError> + Send + 'static,
) -> Result<T, AcpError> {
tokio::task::spawn_blocking(work)
.await
.map_err(|error| AcpError::internal_error().data(error.to_string()))?
}
fn store_error(error: anyhow::Error) -> AcpError {
AcpError::internal_error().data(error.to_string())
}
#[cfg(test)]
#[path = "session_list_tests.rs"]
mod tests;