1use std::collections::HashMap;
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11use crate::cli::{ReasoningEffort, ReasoningSummary, WebSearchMode};
12use crate::mcp::config::McpConfig;
13use crate::session::SessionWriter;
14
15pub const ACP_SESSION_ID_PREFIX: &str = "acp-session";
17
18pub const ACP_SESSION_SEQUENCE_WIDTH: usize = 8;
20
21#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct LocalSessionMetadata {
27 pub local_session_id: String,
29 pub model: Option<String>,
31 pub websearch: Option<WebSearchMode>,
33 pub reasoning_effort: Option<ReasoningEffort>,
35 pub reasoning_summary: Option<ReasoningSummary>,
37}
38
39#[derive(Debug)]
41pub struct AcpServerSession {
42 pub acp_session_id: String,
44 pub metadata: LocalSessionMetadata,
46 pub cwd: PathBuf,
48 pub turn_in_progress: bool,
50 pub session_writer: Option<SessionWriter>,
52 pub mcp_config: Option<McpConfig>,
54}
55
56impl Clone for AcpServerSession {
57 fn clone(&self) -> Self {
60 Self {
61 acp_session_id: self.acp_session_id.clone(),
62 metadata: self.metadata.clone(),
63 cwd: self.cwd.clone(),
64 turn_in_progress: self.turn_in_progress,
65 session_writer: None,
66 mcp_config: self.mcp_config.clone(),
67 }
68 }
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub enum AcpSessionError {
74 DuplicateLocalSession { local_session_id: String },
76 MissingSession { acp_session_id: String },
78 InvalidCwd { cwd: String, reason: String },
80 TurnInProgress { acp_session_id: String },
82 TurnNotActive { acp_session_id: String },
84}
85
86impl fmt::Display for AcpSessionError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 Self::DuplicateLocalSession { local_session_id } => {
90 write!(f, "duplicate local session id: {local_session_id}")
91 }
92 Self::MissingSession { acp_session_id } => {
93 write!(f, "missing session: {acp_session_id}")
94 }
95 Self::InvalidCwd { cwd, reason } => {
96 write!(f, "invalid session cwd `{cwd}`: {reason}")
97 }
98 Self::TurnInProgress { acp_session_id } => {
99 write!(f, "turn already active for session `{acp_session_id}`")
100 }
101 Self::TurnNotActive { acp_session_id } => {
102 write!(f, "no active turn for session `{acp_session_id}`")
103 }
104 }
105 }
106}
107
108impl std::error::Error for AcpSessionError {}
109
110#[derive(Debug, Default)]
112pub struct AcpSessionStore {
113 sessions: HashMap<String, AcpServerSession>,
114 local_to_acp: HashMap<String, String>,
115 next_sequence: u64,
116}
117
118impl AcpSessionStore {
119 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn next_id(&mut self) -> String {
126 self.next_sequence += 1;
127 generate_session_id(self.next_sequence)
128 }
129
130 pub fn create_session(
134 &mut self, local_session_id: impl Into<String>, workspace_root: &Path, requested_cwd: Option<&Path>,
135 ) -> Result<String, AcpSessionError> {
136 let local_session_id = local_session_id.into();
137 if self.local_to_acp.contains_key(&local_session_id) {
138 return Err(AcpSessionError::DuplicateLocalSession { local_session_id });
139 }
140
141 let cwd = validate_and_normalize_cwd(workspace_root, requested_cwd)?;
142 let acp_session_id = self.next_id();
143 let session = AcpServerSession {
144 acp_session_id: acp_session_id.clone(),
145 metadata: LocalSessionMetadata {
146 local_session_id: local_session_id.clone(),
147 model: None,
148 websearch: None,
149 reasoning_effort: None,
150 reasoning_summary: None,
151 },
152 cwd,
153 turn_in_progress: false,
154 session_writer: None,
155 mcp_config: None,
156 };
157
158 self.sessions.insert(acp_session_id.clone(), session);
159 self.local_to_acp.insert(local_session_id, acp_session_id.clone());
160 Ok(acp_session_id)
161 }
162
163 pub fn attach_existing_session(
168 &mut self, acp_session_id: impl Into<String>, local_session_id: impl Into<String>, cwd: &Path,
169 session_writer: Option<SessionWriter>,
170 ) -> Result<String, AcpSessionError> {
171 let acp_session_id = acp_session_id.into();
172 let local_session_id = local_session_id.into();
173 let cwd = validate_and_normalize_cwd(cwd, None)?;
174
175 if self.sessions.contains_key(&acp_session_id) {
176 return Err(AcpSessionError::DuplicateLocalSession { local_session_id });
177 }
178 if self.local_to_acp.contains_key(&local_session_id) {
179 return Err(AcpSessionError::DuplicateLocalSession { local_session_id });
180 }
181
182 let session = AcpServerSession {
183 acp_session_id: acp_session_id.clone(),
184 metadata: LocalSessionMetadata {
185 local_session_id: local_session_id.clone(),
186 model: None,
187 websearch: None,
188 reasoning_effort: None,
189 reasoning_summary: None,
190 },
191 cwd,
192 turn_in_progress: false,
193 session_writer,
194 mcp_config: None,
195 };
196
197 self.sessions.insert(acp_session_id.clone(), session);
198 self.local_to_acp.insert(local_session_id, acp_session_id.clone());
199 Ok(acp_session_id)
200 }
201
202 pub fn acp_session_id_for_local(&self, local_session_id: &str) -> Option<&str> {
204 self.local_to_acp.get(local_session_id).map(String::as_str)
205 }
206
207 pub fn local_session_id(&self, acp_session_id: &str) -> Option<&str> {
209 self.sessions
210 .get(acp_session_id)
211 .map(|session| session.metadata.local_session_id.as_str())
212 }
213
214 pub fn session(&self, acp_session_id: &str) -> Option<&AcpServerSession> {
216 self.sessions.get(acp_session_id)
217 }
218
219 pub fn sessions(&self) -> Vec<&AcpServerSession> {
221 let mut sessions = self.sessions.values().collect::<Vec<_>>();
222 sessions.sort_by(|left, right| left.acp_session_id.cmp(&right.acp_session_id));
223 sessions
224 }
225
226 pub fn begin_turn(&mut self, acp_session_id: &str) -> Result<(), AcpSessionError> {
230 let Some(session) = self.sessions.get_mut(acp_session_id) else {
231 return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
232 };
233 if session.turn_in_progress {
234 return Err(AcpSessionError::TurnInProgress { acp_session_id: acp_session_id.to_string() });
235 }
236 session.turn_in_progress = true;
237 Ok(())
238 }
239
240 pub fn end_turn(&mut self, acp_session_id: &str) -> Result<(), AcpSessionError> {
242 let Some(session) = self.sessions.get_mut(acp_session_id) else {
243 return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
244 };
245 if !session.turn_in_progress {
246 return Err(AcpSessionError::TurnNotActive { acp_session_id: acp_session_id.to_string() });
247 }
248 session.turn_in_progress = false;
249 Ok(())
250 }
251
252 pub fn update_session_metadata(
254 &mut self, acp_session_id: &str, model: Option<String>, websearch: Option<WebSearchMode>,
255 ) -> Result<(), AcpSessionError> {
256 let Some(session) = self.sessions.get_mut(acp_session_id) else {
257 return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
258 };
259 session.metadata.model = model;
260 session.metadata.websearch = websearch;
261 Ok(())
262 }
263
264 pub fn update_session_reasoning(
266 &mut self, acp_session_id: &str, effort: Option<ReasoningEffort>, summary: Option<ReasoningSummary>,
267 ) -> Result<(), AcpSessionError> {
268 let Some(session) = self.sessions.get_mut(acp_session_id) else {
269 return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
270 };
271 session.metadata.reasoning_effort = effort;
272 session.metadata.reasoning_summary = summary;
273 Ok(())
274 }
275
276 pub fn attach_session_writer(
278 &mut self, acp_session_id: &str, session_writer: SessionWriter,
279 ) -> Result<(), AcpSessionError> {
280 let Some(session) = self.sessions.get_mut(acp_session_id) else {
281 return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
282 };
283 session.session_writer = Some(session_writer);
284 Ok(())
285 }
286
287 pub fn attach_mcp_config(&mut self, acp_session_id: &str, mcp_config: McpConfig) -> Result<(), AcpSessionError> {
289 let Some(session) = self.sessions.get_mut(acp_session_id) else {
290 return Err(AcpSessionError::MissingSession { acp_session_id: acp_session_id.to_string() });
291 };
292 session.mcp_config = Some(mcp_config);
293 Ok(())
294 }
295
296 pub fn session_writer(&self, acp_session_id: &str) -> Option<&SessionWriter> {
298 self.sessions
299 .get(acp_session_id)
300 .and_then(|session| session.session_writer.as_ref())
301 }
302
303 pub fn session_writer_mut(&mut self, acp_session_id: &str) -> Option<&mut SessionWriter> {
305 self.sessions
306 .get_mut(acp_session_id)
307 .and_then(|session| session.session_writer.as_mut())
308 }
309
310 pub fn remove_session(&mut self, acp_session_id: &str) -> Option<String> {
312 let session = self.sessions.remove(acp_session_id)?;
313 self.local_to_acp.retain(|_, mapped_acp| mapped_acp != acp_session_id);
314 Some(session.metadata.local_session_id)
315 }
316
317 pub fn is_turn_active(&self, acp_session_id: &str) -> bool {
319 self.sessions
320 .get(acp_session_id)
321 .is_some_and(|session| session.turn_in_progress)
322 }
323
324 pub fn clear_turns_for_test(&mut self) {
326 for session in self.sessions.values_mut() {
327 session.turn_in_progress = false;
328 }
329 }
330}
331
332pub fn generate_session_id(sequence: u64) -> String {
334 let width = ACP_SESSION_SEQUENCE_WIDTH;
335 format!("{ACP_SESSION_ID_PREFIX}-{sequence:0width$}")
336}
337
338pub fn validate_and_normalize_cwd(
343 workspace_root: &Path, requested_cwd: Option<&Path>,
344) -> Result<PathBuf, AcpSessionError> {
345 let requested = requested_cwd.unwrap_or(workspace_root);
346 if requested.as_os_str().is_empty() {
347 return canonicalize_cwd(workspace_root);
348 }
349
350 let candidate = if requested.is_absolute() { requested.to_path_buf() } else { workspace_root.join(requested) };
351 if !candidate.exists() {
352 return Err(AcpSessionError::InvalidCwd {
353 cwd: candidate.to_string_lossy().to_string(),
354 reason: "path does not exist".to_string(),
355 });
356 }
357 if !candidate.is_dir() {
358 return Err(AcpSessionError::InvalidCwd {
359 cwd: candidate.to_string_lossy().to_string(),
360 reason: "path is not a directory".to_string(),
361 });
362 }
363 canonicalize_cwd(&candidate)
364}
365
366fn canonicalize_cwd(path: &Path) -> Result<PathBuf, AcpSessionError> {
367 let normalized = path.canonicalize().map_err(|error| AcpSessionError::InvalidCwd {
368 cwd: path.to_string_lossy().to_string(),
369 reason: error.to_string(),
370 })?;
371 Ok(normalized)
372}