Skip to main content

thndrs_lib/server/
session.rs

1//! ACP server session state primitives.
2//!
3//! The types here are intentionally small and protocol-agnostic: map local
4//! `thndrs` session IDs to opaque ACP IDs, normalize/validate `cwd`, and guard
5//! against concurrent prompt turns per ACP session.
6
7use 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
15/// Prefix for generated opaque ACP session IDs.
16pub const ACP_SESSION_ID_PREFIX: &str = "acp-session";
17
18/// Width of the zero-padded sequence component in generated session IDs.
19pub const ACP_SESSION_SEQUENCE_WIDTH: usize = 8;
20
21/// Lightweight metadata placeholder for local session state.
22///
23/// This tracks local `thndrs` session metadata independently from ACP IDs so
24/// handlers can persist or inspect local session details later.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct LocalSessionMetadata {
27    /// Local append-only session ID used by `thndrs` telemetry and session JSONL.
28    pub local_session_id: String,
29    /// Current model label selected for this ACP session.
30    pub model: Option<String>,
31    /// Current web-search mode selected for this ACP session.
32    pub websearch: Option<WebSearchMode>,
33    /// Current reasoning effort selected for this ACP session.
34    pub reasoning_effort: Option<ReasoningEffort>,
35    /// Current reasoning-summary policy selected for this ACP session.
36    pub reasoning_summary: Option<ReasoningSummary>,
37}
38
39/// Runtime state for one active ACP session.
40#[derive(Debug)]
41pub struct AcpServerSession {
42    /// Opaque ACP session ID.
43    pub acp_session_id: String,
44    /// Local metadata placeholder.
45    pub metadata: LocalSessionMetadata,
46    /// Validated and normalized working directory.
47    pub cwd: PathBuf,
48    /// Whether an ACP turn is currently active.
49    pub turn_in_progress: bool,
50    /// Optional local session writer persisted to JSONL.
51    pub session_writer: Option<SessionWriter>,
52    /// MCP servers accepted from ACP `session/new`.
53    pub mcp_config: Option<McpConfig>,
54}
55
56impl Clone for AcpServerSession {
57    /// Clone only serializable session state. A local writer is intentionally
58    /// not duplicated because it owns an exclusive append lock.
59    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/// Errors for ACP session state operations.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub enum AcpSessionError {
74    /// The requested local session already exists.
75    DuplicateLocalSession { local_session_id: String },
76    /// ACP session lookup by ID failed.
77    MissingSession { acp_session_id: String },
78    /// Session `cwd` is not valid or could not be normalized.
79    InvalidCwd { cwd: String, reason: String },
80    /// A turn is already running in this ACP session.
81    TurnInProgress { acp_session_id: String },
82    /// No turn is active in this ACP session.
83    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/// Deterministic ACP session state map used by protocol handlers.
111#[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    /// Create an empty store.
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Generate a deterministic opaque ACP session id.
125    pub fn next_id(&mut self) -> String {
126        self.next_sequence += 1;
127        generate_session_id(self.next_sequence)
128    }
129
130    /// Create a new ACP session and attach local session metadata placeholders.
131    ///
132    /// Returns `Err` when the local session id already exists.
133    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    /// Attach an existing local session to a caller-supplied ACP session id.
164    ///
165    /// Used by `session/load` and `session/resume`, where the client names a
166    /// persisted session returned from `session/list`.
167    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    /// Return the opaque ACP session id for a local session id.
203    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    /// Return the local session id for an ACP session id.
208    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    /// Return a snapshot of one ACP session.
215    pub fn session(&self, acp_session_id: &str) -> Option<&AcpServerSession> {
216        self.sessions.get(acp_session_id)
217    }
218
219    /// Return snapshots of all active ACP sessions sorted by ACP id.
220    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    /// Mark a turn as active for this ACP session.
227    ///
228    /// This is a strict guard: only one active turn is allowed per session.
229    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    /// Mark a turn as complete for this ACP session.
241    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    /// Update local metadata placeholders for a session.
253    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    /// Update reasoning controls without changing model or web-search metadata.
265    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    /// Attach a local session writer for persistence.
277    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    /// Attach ACP-provided MCP server config to a session.
288    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    /// Return the active session writer when persistence is enabled.
297    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    /// Return a mutable session writer when persistence is enabled.
304    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    /// Drop a stored session and return its local session id when present.
311    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    /// Return `true` when the ACP session currently has an active turn.
318    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    /// Force clear all active turns in store state. Intended for tests only.
325    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
332/// Generate deterministic opaque ACP session ids.
333pub fn generate_session_id(sequence: u64) -> String {
334    let width = ACP_SESSION_SEQUENCE_WIDTH;
335    format!("{ACP_SESSION_ID_PREFIX}-{sequence:0width$}")
336}
337
338/// Normalize and validate a workspace cwd for a new ACP session.
339///
340/// Relative paths are resolved against `workspace_root`. Result paths must
341/// exist, be directories, and be normalized to an absolute path.
342pub 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}