use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::cli::{ReasoningEffort, ReasoningSummary, WebSearchMode};
use crate::mcp::config::McpConfig;
use crate::session::SessionWriter;
pub const ACP_SESSION_ID_PREFIX: &str = "acp-session";
pub const ACP_SESSION_SEQUENCE_WIDTH: usize = 8;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LocalSessionMetadata {
pub local_session_id: String,
pub model: Option<String>,
pub websearch: Option<WebSearchMode>,
pub reasoning_effort: Option<ReasoningEffort>,
pub reasoning_summary: Option<ReasoningSummary>,
}
#[derive(Debug)]
pub struct AcpServerSession {
pub acp_session_id: String,
pub metadata: LocalSessionMetadata,
pub cwd: PathBuf,
pub turn_in_progress: bool,
pub session_writer: Option<SessionWriter>,
pub mcp_config: Option<McpConfig>,
}
impl Clone for AcpServerSession {
fn clone(&self) -> Self {
Self {
acp_session_id: self.acp_session_id.clone(),
metadata: self.metadata.clone(),
cwd: self.cwd.clone(),
turn_in_progress: self.turn_in_progress,
session_writer: None,
mcp_config: self.mcp_config.clone(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AcpSessionError {
DuplicateLocalSession { local_session_id: String },
MissingSession { acp_session_id: String },
InvalidCwd { cwd: String, reason: String },
TurnInProgress { acp_session_id: String },
TurnNotActive { acp_session_id: String },
}
impl fmt::Display for AcpSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateLocalSession { local_session_id } => {
write!(f, "duplicate local session id: {local_session_id}")
}
Self::MissingSession { acp_session_id } => {
write!(f, "missing session: {acp_session_id}")
}
Self::InvalidCwd { cwd, reason } => {
write!(f, "invalid session cwd `{cwd}`: {reason}")
}
Self::TurnInProgress { acp_session_id } => {
write!(f, "turn already active for session `{acp_session_id}`")
}
Self::TurnNotActive { acp_session_id } => {
write!(f, "no active turn for session `{acp_session_id}`")
}
}
}
}
impl std::error::Error for AcpSessionError {}
#[derive(Debug, Default)]
pub struct AcpSessionStore {
sessions: HashMap<String, AcpServerSession>,
local_to_acp: HashMap<String, String>,
next_sequence: u64,
}
impl AcpSessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn next_id(&mut self) -> String {
self.next_sequence += 1;
generate_session_id(self.next_sequence)
}
pub fn create_session(
&mut self, local_session_id: impl Into<String>, workspace_root: &Path, requested_cwd: Option<&Path>,
) -> Result<String, AcpSessionError> {
let local_session_id = local_session_id.into();
if self.local_to_acp.contains_key(&local_session_id) {
return Err(AcpSessionError::DuplicateLocalSession { local_session_id });
}
let cwd = validate_and_normalize_cwd(workspace_root, requested_cwd)?;
let acp_session_id = self.next_id();
let session = AcpServerSession {
acp_session_id: acp_session_id.clone(),
metadata: LocalSessionMetadata {
local_session_id: local_session_id.clone(),
model: None,
websearch: None,
reasoning_effort: None,
reasoning_summary: None,
},
cwd,
turn_in_progress: false,
session_writer: None,
mcp_config: None,
};
self.sessions.insert(acp_session_id.clone(), session);
self.local_to_acp.insert(local_session_id, acp_session_id.clone());
Ok(acp_session_id)
}
pub fn attach_existing_session(
&mut self, acp_session_id: impl Into<String>, local_session_id: impl Into<String>, cwd: &Path,
session_writer: Option<SessionWriter>,
) -> Result<String, AcpSessionError> {
let acp_session_id = acp_session_id.into();
let local_session_id = local_session_id.into();
let cwd = validate_and_normalize_cwd(cwd, None)?;
if self.sessions.contains_key(&acp_session_id) {
return Err(AcpSessionError::DuplicateLocalSession { local_session_id });
}
if self.local_to_acp.contains_key(&local_session_id) {
return Err(AcpSessionError::DuplicateLocalSession { local_session_id });
}
let session = AcpServerSession {
acp_session_id: acp_session_id.clone(),
metadata: LocalSessionMetadata {
local_session_id: local_session_id.clone(),
model: None,
websearch: None,
reasoning_effort: None,
reasoning_summary: None,
},
cwd,
turn_in_progress: false,
session_writer,
mcp_config: None,
};
self.sessions.insert(acp_session_id.clone(), session);
self.local_to_acp.insert(local_session_id, acp_session_id.clone());
Ok(acp_session_id)
}
pub fn acp_session_id_for_local(&self, local_session_id: &str) -> Option<&str> {
self.local_to_acp.get(local_session_id).map(String::as_str)
}
pub fn local_session_id(&self, acp_session_id: &str) -> Option<&str> {
self.sessions
.get(acp_session_id)
.map(|session| session.metadata.local_session_id.as_str())
}
pub fn session(&self, acp_session_id: &str) -> Option<&AcpServerSession> {
self.sessions.get(acp_session_id)
}
pub fn sessions(&self) -> Vec<&AcpServerSession> {
let mut sessions = self.sessions.values().collect::<Vec<_>>();
sessions.sort_by(|left, right| left.acp_session_id.cmp(&right.acp_session_id));
sessions
}
pub fn begin_turn(&mut self, acp_session_id: &str) -> Result<(), AcpSessionError> {
let Some(session) = self.sessions.get_mut(acp_session_id) else {
return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
};
if session.turn_in_progress {
return Err(AcpSessionError::TurnInProgress { acp_session_id: acp_session_id.to_string() });
}
session.turn_in_progress = true;
Ok(())
}
pub fn end_turn(&mut self, acp_session_id: &str) -> Result<(), AcpSessionError> {
let Some(session) = self.sessions.get_mut(acp_session_id) else {
return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
};
if !session.turn_in_progress {
return Err(AcpSessionError::TurnNotActive { acp_session_id: acp_session_id.to_string() });
}
session.turn_in_progress = false;
Ok(())
}
pub fn update_session_metadata(
&mut self, acp_session_id: &str, model: Option<String>, websearch: Option<WebSearchMode>,
) -> Result<(), AcpSessionError> {
let Some(session) = self.sessions.get_mut(acp_session_id) else {
return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
};
session.metadata.model = model;
session.metadata.websearch = websearch;
Ok(())
}
pub fn update_session_reasoning(
&mut self, acp_session_id: &str, effort: Option<ReasoningEffort>, summary: Option<ReasoningSummary>,
) -> Result<(), AcpSessionError> {
let Some(session) = self.sessions.get_mut(acp_session_id) else {
return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
};
session.metadata.reasoning_effort = effort;
session.metadata.reasoning_summary = summary;
Ok(())
}
pub fn attach_session_writer(
&mut self, acp_session_id: &str, session_writer: SessionWriter,
) -> Result<(), AcpSessionError> {
let Some(session) = self.sessions.get_mut(acp_session_id) else {
return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
};
session.session_writer = Some(session_writer);
Ok(())
}
pub fn attach_mcp_config(&mut self, acp_session_id: &str, mcp_config: McpConfig) -> Result<(), AcpSessionError> {
let Some(session) = self.sessions.get_mut(acp_session_id) else {
return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
};
session.mcp_config = Some(mcp_config);
Ok(())
}
pub fn session_writer(&self, acp_session_id: &str) -> Option<&SessionWriter> {
self.sessions
.get(acp_session_id)
.and_then(|session| session.session_writer.as_ref())
}
pub fn session_writer_mut(&mut self, acp_session_id: &str) -> Option<&mut SessionWriter> {
self.sessions
.get_mut(acp_session_id)
.and_then(|session| session.session_writer.as_mut())
}
pub fn remove_session(&mut self, acp_session_id: &str) -> Option<String> {
let session = self.sessions.remove(acp_session_id)?;
self.local_to_acp.retain(|_, mapped_acp| mapped_acp != acp_session_id);
Some(session.metadata.local_session_id)
}
pub fn is_turn_active(&self, acp_session_id: &str) -> bool {
self.sessions
.get(acp_session_id)
.is_some_and(|session| session.turn_in_progress)
}
pub fn clear_turns_for_test(&mut self) {
for session in self.sessions.values_mut() {
session.turn_in_progress = false;
}
}
}
pub fn generate_session_id(sequence: u64) -> String {
let width = ACP_SESSION_SEQUENCE_WIDTH;
format!("{ACP_SESSION_ID_PREFIX}-{sequence:0width$}")
}
pub fn validate_and_normalize_cwd(
workspace_root: &Path, requested_cwd: Option<&Path>,
) -> Result<PathBuf, AcpSessionError> {
let requested = requested_cwd.unwrap_or(workspace_root);
if requested.as_os_str().is_empty() {
return canonicalize_cwd(workspace_root);
}
let candidate = if requested.is_absolute() { requested.to_path_buf() } else { workspace_root.join(requested) };
if !candidate.exists() {
return Err(AcpSessionError::InvalidCwd {
cwd: candidate.to_string_lossy().to_string(),
reason: "path does not exist".to_string(),
});
}
if !candidate.is_dir() {
return Err(AcpSessionError::InvalidCwd {
cwd: candidate.to_string_lossy().to_string(),
reason: "path is not a directory".to_string(),
});
}
canonicalize_cwd(&candidate)
}
fn canonicalize_cwd(path: &Path) -> Result<PathBuf, AcpSessionError> {
let normalized = path.canonicalize().map_err(|error| AcpSessionError::InvalidCwd {
cwd: path.to_string_lossy().to_string(),
reason: error.to_string(),
})?;
Ok(normalized)
}