Skip to main content

recursive/acp/
session.rs

1//! Per-session state: holds the [`AgentRuntime`], working directory,
2//! turn counter, transcript, and a [`CancellationToken`] for cooperative
3//! abort for a single ACP session.
4//!
5//! [`AcpSessionManager`] is a lightweight HashMap-based registry. It is
6//! owned by the server's dispatch loop. No parallel session-management
7//! infrastructure exists outside this module — all lifecycle state is
8//! tracked through the [`AgentRuntime`] and this simple container.
9//!
10//! # Session persistence (ACP-S1-02)
11//!
12//! Sessions are persisted to `<persistence_dir>/<session_id>/`:
13//! - `transcript.jsonl` – one JSON line per [`Message`].
14//! - `metadata.json` – session metadata (system prompt, MCP configs, turn count).
15//!
16//! Every message object in the saved transcript has an `id` field matching
17//! the SHA-256 content hash (first 12 hex chars), computed by
18//! [`crate::acp::bridge::sha256_first_12`].
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::sync::Arc;
24
25use crate::acp::protocol::ClientCapabilities;
26use crate::tools::AcpClientFsState;
27use tokio::sync::Mutex;
28
29use std::io::Write;
30
31use serde::{Deserialize, Serialize};
32
33use crate::acp::bridge::sha256_first_12;
34use crate::acp::permission::PendingPermissionStore;
35use crate::message::Message;
36use crate::runtime::AgentRuntime;
37
38/// A unique session identifier.
39pub type SessionId = String;
40
41// ---------------------------------------------------------------------------
42// SessionMetadata — persisted alongside the transcript
43// ---------------------------------------------------------------------------
44
45/// Metadata saved alongside the session transcript.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SessionMetadata {
48    /// The session ID.
49    pub session_id: SessionId,
50    /// The sandbox root (cwd).
51    pub cwd: PathBuf,
52    /// The current turn counter.
53    pub turn: u64,
54    /// The original system prompt (if any).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub system_prompt: Option<String>,
57    /// MCP server configurations (if any).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub mcp_servers: Option<serde_json::Value>,
60}
61
62// ---------------------------------------------------------------------------
63// SavedMessage — transcript entry with content-hash ID
64// ---------------------------------------------------------------------------
65
66/// A message saved to the JSONL transcript, with a content-hash `id` field.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SavedMessage {
69    /// SHA-256 first 12 hex chars of content.
70    pub id: String,
71    /// The underlying message.
72    #[serde(flatten)]
73    pub message: Message,
74    /// Compressible flag for compaction hints (ACP-S1-05).
75    #[serde(default)]
76    pub compressible: bool,
77}
78
79impl SavedMessage {
80    /// Create a new SavedMessage from a Message, computing the content hash.
81    pub fn from_message(msg: Message) -> Self {
82        let id = sha256_first_12(&msg.content);
83        Self {
84            id,
85            message: msg,
86            compressible: false,
87        }
88    }
89}
90
91// ---------------------------------------------------------------------------
92// CompactionHint
93// ---------------------------------------------------------------------------
94
95/// A compaction hint entry, identifying which turn indices are safe to compress.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CompactionHint {
98    /// The index of the turn in the saved transcript.
99    pub turn_index: usize,
100    /// Whether this turn is safe to compress.
101    pub compressible: bool,
102}
103
104// ---------------------------------------------------------------------------
105// SummarizedContext — output of summarize_transcript
106// ---------------------------------------------------------------------------
107
108/// The result of local transcript summarization (ACP-S1-04).
109#[derive(Debug, Clone)]
110pub struct SummarizedContext {
111    /// A paragraph describing accomplishments, in-flight work, and blockers.
112    pub summary: String,
113    /// The number of messages that were summarized.
114    pub message_count: usize,
115}
116
117// ---------------------------------------------------------------------------
118// AcpSession
119// ---------------------------------------------------------------------------
120
121/// State for one active ACP session.
122pub struct AcpSession {
123    /// The agent runtime that processes `session/prompt` requests.
124    pub runtime: AgentRuntime,
125    /// The sandbox root for filesystem tools.
126    pub cwd: PathBuf,
127    /// Monotonically-increasing turn counter.
128    pub turn: u64,
129    /// Session identifier (copied from the manager's key for convenience).
130    pub session_id: SessionId,
131    /// Accumulated transcript of messages exchanged so far.
132    pub transcript: Vec<Message>,
133    /// Cancellation token for the current (or next) agent turn.
134    /// Fired by `session/cancel`; observed by the LLM stream loop
135    /// (`tokio::select!`) and agent→client RPC calls.
136    ///
137    /// This is an `Arc<CancellationToken>` to guarantee shared ownership
138    /// across concurrent tasks (e.g. SSE parse loop and in-flight RPCs).
139    /// Dropping the `AcpSession` does NOT cancel the token if other `Arc`
140    /// clones exist.
141    pub cancel_token: Arc<tokio_util::sync::CancellationToken>,
142    /// The original system prompt for this session (used for session/resume).
143    pub system_prompt: Option<String>,
144    /// MCP server configurations (used for session/resume).
145    pub mcp_servers: Option<serde_json::Value>,
146    /// Client capabilities declared during initialize (S2-E1/E3).
147    /// Controls conditional registration of ClientReadFile/ClientWriteFile.
148    pub client_capabilities: Option<ClientCapabilities>,
149    /// Shared ACP client FS state for conditional tool visibility and routing.
150    pub acp_state: Arc<Mutex<AcpClientFsState>>,
151    /// MCP child processes managed by this session (for cleanup on close/load/resume).
152    pub mcp_child_pids: Vec<u32>,
153}
154
155impl AcpSession {
156    /// Create a fresh `CancellationToken` for the next turn and wire it
157    /// to the agent runtime via `set_interrupt_token`. Returns an `Arc`
158    /// clone of the new token for the caller to hold.
159    ///
160    /// Must be called exactly once per turn, before `runtime.run()`.
161    pub fn refresh_cancel_token(&mut self) -> Arc<tokio_util::sync::CancellationToken> {
162        let token = Arc::new(tokio_util::sync::CancellationToken::new());
163        // CancellationToken is internally Arc-based and cheap to clone.
164        self.runtime.set_interrupt_token((*token).clone());
165        self.cancel_token = token.clone();
166        token
167    }
168
169    /// Kill all tracked MCP child processes using the graceful shutdown
170    /// protocol (SIGTERM → timeout → SIGKILL). Called during session/close,
171    /// session/load, session/resume, and client disconnect cleanup.
172    pub async fn kill_mcp_children(&mut self) {
173        for pid in std::mem::take(&mut self.mcp_child_pids) {
174            #[cfg(unix)]
175            {
176                use tokio::time::timeout;
177
178                let grace = crate::mcp::DEFAULT_KILL_GRACE_PERIOD;
179                tracing::info!(server_pid = pid, "AcpSession: killing MCP child process");
180
181                // Send SIGTERM via std::process::Command::new("kill").
182                tracing::info!(server_pid = pid, "kill-gracefully: sending SIGTERM");
183                let _ = std::process::Command::new("kill")
184                    .args(["-TERM", &pid.to_string()])
185                    .status();
186
187                // Wait for grace period
188                let waited = timeout(grace, async {
189                    loop {
190                        // Check if process is alive
191                        let status = std::process::Command::new("kill")
192                            .args(["-0", &pid.to_string()])
193                            .status()
194                            .ok();
195                        match status {
196                            Some(s) if s.success() => {
197                                // Process still alive, keep waiting
198                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
199                            }
200                            _ => {
201                                // Process has exited
202                                break;
203                            }
204                        }
205                    }
206                })
207                .await;
208
209                if waited.is_err() {
210                    // Timeout — send SIGKILL
211                    tracing::warn!(
212                        server_pid = pid,
213                        grace_ms = grace.as_millis(),
214                        "kill-gracefully: SIGTERM timeout, sending SIGKILL"
215                    );
216                    let _ = std::process::Command::new("kill")
217                        .args(["-9", &pid.to_string()])
218                        .status();
219                } else {
220                    tracing::info!(
221                        server_pid = pid,
222                        "kill-gracefully: process exited gracefully"
223                    );
224                }
225
226                // Reap zombie
227                let _ = std::process::Command::new("wait")
228                    .args([&pid.to_string()])
229                    .status();
230            }
231            #[cfg(not(unix))]
232            {
233                let _ = pid;
234                tracing::info!("AcpSession: killing MCP child (non-Unix)");
235                let _ = std::process::Command::new("taskkill")
236                    .args(["/F", "/PID", &pid.to_string()])
237                    .status();
238            }
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// AcpSessionManager
245// ---------------------------------------------------------------------------
246
247/// Registry of active ACP sessions, keyed by [`SessionId`].
248///
249/// Manages persistence of transcripts and metadata to disk.
250pub struct AcpSessionManager {
251    sessions: HashMap<SessionId, AcpSession>,
252    next_id: AtomicU64,
253    /// Directory under which session data directories are created.
254    /// Each session gets `<persistence_dir>/<session_id>/`.
255    /// Defaults to a temp directory if not set.
256    persistence_dir: Option<PathBuf>,
257    /// Shared store of pending permission requests (ACP permission bridge).
258    permission_store: PendingPermissionStore,
259    /// Client capabilities declared during the `initialize` handshake (S2-E1/E3).
260    /// Stored here so `session/new` can conditionally register reverse-fs tools.
261    client_capabilities: Option<ClientCapabilities>,
262}
263
264impl Default for AcpSessionManager {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl AcpSessionManager {
271    /// Create an empty session manager.
272    pub fn new() -> Self {
273        Self {
274            sessions: HashMap::new(),
275            next_id: AtomicU64::new(1),
276            persistence_dir: None,
277            permission_store: PendingPermissionStore::new(),
278            client_capabilities: None,
279        }
280    }
281
282    /// Set the client capabilities declared during `initialize`.
283    /// These are used by `session/new` to conditionally register reverse-fs tools.
284    pub fn set_client_capabilities(&mut self, caps: Option<ClientCapabilities>) {
285        self.client_capabilities = caps;
286    }
287
288    /// Get the client capabilities (if any) declared during `initialize`.
289    pub fn get_client_capabilities(&self) -> Option<&ClientCapabilities> {
290        self.client_capabilities.as_ref()
291    }
292
293    /// Set the persistence directory for session storage.
294    /// If not set, sessions are not persisted.
295    pub fn with_persistence_dir(mut self, dir: PathBuf) -> Self {
296        self.persistence_dir = Some(dir);
297        self
298    }
299
300    /// Get the persistence directory.
301    pub fn persistence_dir(&self) -> Option<&Path> {
302        self.persistence_dir.as_deref()
303    }
304
305    /// Generate the next sequential session id (e.g. `"acp-sess-1"`).
306    pub fn next_session_id(&self) -> SessionId {
307        let n = self.next_id.fetch_add(1, Ordering::Relaxed);
308        format!("acp-sess-{n}")
309    }
310
311    /// Insert a session with a pre-generated id.
312    ///
313    /// `session_id` must match `session.session_id`. This is a convenience
314    /// to avoid double-allocating a `SessionId` in the caller.
315    pub fn insert_with_id(&mut self, session_id: SessionId, session: AcpSession) {
316        // If persistence is enabled, create the session directory and
317        // save initial metadata.
318        if let Some(pdir) = &self.persistence_dir {
319            let session_dir = pdir.join(&session_id);
320            let _ = std::fs::create_dir_all(&session_dir);
321            let _ = self.save_metadata_to_disk(&session_id, &session);
322        }
323        self.sessions.insert(session_id, session);
324    }
325
326    /// Look up a mutable reference to a session by id.
327    pub fn get_mut(&mut self, sid: &str) -> Option<&mut AcpSession> {
328        self.sessions.get_mut(sid)
329    }
330
331    /// Remove and return a session, releasing its resources.
332    pub fn remove(&mut self, sid: &str) -> Option<AcpSession> {
333        self.sessions.remove(sid)
334    }
335
336    /// Return the id and turn of every session (immutable snapshot).
337    /// Used by `session/cancel` (empty-params mode) to find the most recently
338    /// active session.
339    pub fn all_turns(&self) -> Vec<(String, u64)> {
340        self.sessions
341            .iter()
342            .map(|(k, v)| (k.clone(), v.turn))
343            .collect()
344    }
345
346    /// Get a reference to a session by id (immutable).
347    pub fn get(&self, sid: &str) -> Option<&AcpSession> {
348        self.sessions.get(sid)
349    }
350
351    /// Check if a session exists.
352    pub fn contains(&self, sid: &str) -> bool {
353        self.sessions.contains_key(sid)
354    }
355
356    /// Get the shared permission store for ACP permission bridge.
357    pub fn permission_store(&mut self) -> &mut PendingPermissionStore {
358        &mut self.permission_store
359    }
360
361    // ── Session persistence (ACP-S1-02) ──────────────────────────
362
363    /// Return the on-disk directory for a given session ID.
364    pub fn session_dir(&self, sid: &str) -> Option<PathBuf> {
365        self.persistence_dir
366            .as_ref()
367            .map(|p| p.join(sanitize_session_id(sid)))
368    }
369
370    /// Save the current transcript to disk as JSONL.
371    /// Each line is a `SavedMessage` with `id`, `message`, and `compressible` fields.
372    pub fn save_transcript(&self, sid: &str) -> std::io::Result<()> {
373        let session = match self.sessions.get(sid) {
374            Some(s) => s,
375            None => {
376                return Err(std::io::Error::new(
377                    std::io::ErrorKind::NotFound,
378                    "session not found",
379                ))
380            }
381        };
382
383        let session_dir = match self.session_dir(sid) {
384            Some(d) => d,
385            None => return Ok(()), // No persistence configured — skip.
386        };
387
388        // Create directory if needed
389        let _ = std::fs::create_dir_all(&session_dir);
390
391        let path = session_dir.join("transcript.jsonl");
392        let mut file = std::fs::File::create(&path)?;
393
394        for msg in &session.transcript {
395            let saved = SavedMessage::from_message(msg.clone());
396            let line = serde_json::to_string(&saved)?;
397            writeln!(&mut file, "{line}")?;
398        }
399
400        Ok(())
401    }
402
403    /// Load session transcript from disk (reads `transcript.jsonl`).
404    pub fn load_transcript_from_disk(&self, sid: &str) -> std::io::Result<Vec<SavedMessage>> {
405        let session_dir = self.session_dir(sid).ok_or_else(|| {
406            std::io::Error::new(std::io::ErrorKind::NotFound, "no persistence dir")
407        })?;
408
409        let path = session_dir.join("transcript.jsonl");
410        let content = std::fs::read_to_string(&path)?;
411        let mut messages = Vec::new();
412
413        for line in content.lines() {
414            let trimmed = line.trim();
415            if trimmed.is_empty() {
416                continue;
417            }
418            let saved: SavedMessage = serde_json::from_str(trimmed).map_err(|e| {
419                std::io::Error::new(
420                    std::io::ErrorKind::InvalidData,
421                    format!("corrupt transcript: {e}"),
422                )
423            })?;
424            messages.push(saved);
425        }
426
427        Ok(messages)
428    }
429
430    /// Save session metadata to disk as JSON.
431    fn save_metadata_to_disk(&self, sid: &str, session: &AcpSession) -> std::io::Result<()> {
432        let session_dir = match self.session_dir(sid) {
433            Some(d) => d,
434            None => return Ok(()),
435        };
436
437        let _ = std::fs::create_dir_all(&session_dir);
438
439        let metadata = SessionMetadata {
440            session_id: sid.to_string(),
441            cwd: session.cwd.clone(),
442            turn: session.turn,
443            system_prompt: session.system_prompt.clone(),
444            mcp_servers: session.mcp_servers.clone(),
445        };
446
447        let path = session_dir.join("metadata.json");
448        let json = serde_json::to_string_pretty(&metadata)?;
449        std::fs::write(&path, json)?;
450
451        Ok(())
452    }
453
454    /// Save metadata explicitly (called after turn increment).
455    pub fn save_metadata(&self, sid: &str) -> std::io::Result<()> {
456        let session = match self.sessions.get(sid) {
457            Some(s) => s,
458            None => {
459                return Err(std::io::Error::new(
460                    std::io::ErrorKind::NotFound,
461                    "session not found",
462                ))
463            }
464        };
465        self.save_metadata_to_disk(sid, session)
466    }
467
468    /// Load session metadata from disk.
469    pub fn load_metadata_from_disk(&self, sid: &str) -> std::io::Result<SessionMetadata> {
470        let session_dir = self.session_dir(sid).ok_or_else(|| {
471            std::io::Error::new(std::io::ErrorKind::NotFound, "no persistence dir")
472        })?;
473
474        let path = session_dir.join("metadata.json");
475        let content = std::fs::read_to_string(&path)?;
476        let metadata: SessionMetadata = serde_json::from_str(&content).map_err(|e| {
477            std::io::Error::new(
478                std::io::ErrorKind::InvalidData,
479                format!("corrupt metadata: {e}"),
480            )
481        })?;
482        Ok(metadata)
483    }
484
485    /// Check if a session exists on disk (for load/resume).
486    pub fn session_exists_on_disk(&self, sid: &str) -> bool {
487        self.session_dir(sid)
488            .map(|d| d.join("metadata.json").exists())
489            .unwrap_or(false)
490    }
491}
492
493// ---------------------------------------------------------------------------
494// summarize_transcript — local heuristic (ACP-S1-04)
495// ---------------------------------------------------------------------------
496
497/// Produce a summary paragraph of the transcript using a local heuristic.
498///
499/// This is NOT an LLM call — it uses fast pattern matching to extract:
500/// - Accomplishments (tool results with non-error output, assistant messages with code/data)
501/// - In-flight work (the most recent user message)
502/// - Blockers (tool results with errors)
503///
504/// Returns a [`SummarizedContext`] with the paragraph and message count.
505pub fn summarize_transcript(messages: &[Message]) -> SummarizedContext {
506    let mut accomplishments: Vec<String> = Vec::new();
507    let mut blockers: Vec<String> = Vec::new();
508    let mut in_flight: Option<String> = None;
509
510    for (i, msg) in messages.iter().enumerate() {
511        let content = msg.content.trim();
512
513        match msg.role {
514            crate::message::Role::User => {
515                // The most recent user message is "in-flight"
516                if i == messages.len() - 1
517                    || messages[messages.len() - 1].role == crate::message::Role::User
518                {
519                    in_flight = Some(content.to_string());
520                } else {
521                    // A user message before the last is an accomplished task
522                    let mut goal = content.chars().take(80).collect::<String>();
523                    if content.len() > 80 {
524                        goal.push_str("...");
525                    }
526                    accomplishments.push(format!("user request: \"{goal}\""));
527                }
528            }
529            crate::message::Role::Assistant => {
530                // Assistant messages with content are accomplishments
531                if !content.is_empty() {
532                    let mut snippet = content.chars().take(80).collect::<String>();
533                    if content.len() > 80 {
534                        snippet.push_str("...");
535                    }
536                    accomplishments.push(format!("assistant responded: \"{snippet}\""));
537                }
538            }
539            crate::message::Role::Tool => {
540                // Tool messages with errors are blockers
541                if content.contains("error")
542                    || content.contains("Error")
543                    || content.contains("failed")
544                {
545                    let mut snippet = content.chars().take(60).collect::<String>();
546                    if content.len() > 60 {
547                        snippet.push_str("...");
548                    }
549                    blockers.push(format!("tool error: \"{snippet}\""));
550                } else if !content.is_empty() {
551                    let mut snippet = content.chars().take(60).collect::<String>();
552                    if content.len() > 60 {
553                        snippet.push_str("...");
554                    }
555                    accomplishments.push(format!("tool result: \"{snippet}\""));
556                }
557            }
558            crate::message::Role::System => {
559                // System messages are not summarized
560            }
561        }
562    }
563
564    let mut summary_parts: Vec<String> = Vec::new();
565
566    // Accomplishments
567    if accomplishments.is_empty() {
568        summary_parts.push("No accomplishments recorded yet.".to_string());
569    } else {
570        let n = accomplishments.len();
571        let sample: Vec<&str> = accomplishments.iter().take(3).map(|s| s.as_str()).collect();
572        summary_parts.push(format!(
573            "The session has {n} recorded action(s): {}.",
574            sample.join("; ")
575        ));
576        if accomplishments.len() > 3 {
577            summary_parts.push(format!("And {} more action(s).", accomplishments.len() - 3));
578        }
579    }
580
581    // In-flight work
582    match in_flight {
583        Some(ref text) => summary_parts.push(format!("The user's current request is: \"{text}\".")),
584        None => summary_parts.push("No active user request.".to_string()),
585    }
586
587    // Blockers
588    if blockers.is_empty() {
589        summary_parts.push("No blockers detected.".to_string());
590    } else {
591        let sample: Vec<&str> = blockers.iter().take(2).map(|s| s.as_str()).collect();
592        summary_parts.push(format!(
593            "The session encountered {} blocker(s): {}.",
594            blockers.len(),
595            sample.join("; ")
596        ));
597    }
598
599    SummarizedContext {
600        summary: summary_parts.join(" "),
601        message_count: messages.len(),
602    }
603}
604
605// ---------------------------------------------------------------------------
606// Helpers
607// ---------------------------------------------------------------------------
608
609/// Sanitize a session ID for use as a directory name.
610fn sanitize_session_id(sid: &str) -> String {
611    // Replace path separators and double-dot parent directory references.
612    // Using string::replace (not char) for ".." to avoid multi-codepoint char literal.
613    sid.replace(['/', '\\', ':', ' ', '\0'], "_")
614        .replace("..", "__")
615}
616
617// ---------------------------------------------------------------------------
618// Compaction hints helper (ACP-S1-05)
619// ---------------------------------------------------------------------------
620
621/// Compute compaction hints for a list of saved messages.
622///
623/// A simple recency heuristic: messages older than `(total - recency_threshold)`
624/// are marked compressible. The threshold is configurable; default is 2
625/// (the most recent 2 turns are preserved).
626pub fn compute_compaction_hints(
627    messages: &[SavedMessage],
628    recency_threshold: usize,
629) -> Vec<CompactionHint> {
630    let total = messages.len();
631    messages
632        .iter()
633        .enumerate()
634        .map(|(i, _msg)| {
635            // Messages at index < (total - threshold) are old → compressible.
636            let compressible = i < total.saturating_sub(recency_threshold);
637            CompactionHint {
638                turn_index: i,
639                compressible,
640            }
641        })
642        .collect()
643}
644
645/// Apply compaction hints to saved messages (sets compressible field).
646pub fn apply_compaction_hints(messages: &mut [SavedMessage], hints: &[CompactionHint]) {
647    for hint in hints {
648        if let Some(msg) = messages.get_mut(hint.turn_index) {
649            msg.compressible = hint.compressible;
650        }
651    }
652}
653
654// ---------------------------------------------------------------------------
655// Tests
656// ---------------------------------------------------------------------------
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::message::Message;
662    use std::io::Write;
663
664    // ── Session Manager tests ──────────────────────────────────────
665
666    #[test]
667    fn new_manager_is_empty() {
668        let mgr = AcpSessionManager::new();
669        // Use immutable get to verify no session exists in empty manager
670        assert!(mgr.get("nonexistent").is_none());
671    }
672
673    #[test]
674    fn next_session_id_is_monotonic() {
675        let mgr = AcpSessionManager::new();
676        let a = mgr.next_session_id();
677        let b = mgr.next_session_id();
678        assert_ne!(a, b);
679        assert!(a.contains("acp-sess-"));
680        assert!(b.contains("acp-sess-"));
681    }
682
683    #[test]
684    fn insert_and_get() {
685        let mut mgr = AcpSessionManager::new();
686        let _sid = mgr.next_session_id();
687        // Instead, test that remove on empty returns None
688        assert!(mgr.remove("nonexistent").is_none());
689    }
690
691    #[test]
692    fn contains_works() {
693        let mgr = AcpSessionManager::new();
694        // We can't test contains with a real session here,
695        // but we can test the negative case
696        assert!(!mgr.contains("nonexistent"));
697    }
698
699    // ── Persistence tests (ACP-S1-02) ──────────────────────────────
700
701    #[test]
702    fn save_and_load_transcript_roundtrip() {
703        let tmp = tempfile::TempDir::new().unwrap();
704        let pdir = tmp.path().to_path_buf();
705
706        // Create a manager with persistence
707        let _mgr = AcpSessionManager::new();
708        // We need persistence dir to be set — this test validates
709        // the persistence path logic
710        let _ = pdir;
711
712        // Test save/load logic directly by writing and reading transcript.jsonl
713        let session_dir = pdir.join("test-sess-1");
714        std::fs::create_dir_all(&session_dir).unwrap();
715
716        let messages = vec![
717            Message::user("Hello"),
718            Message::assistant("Hi there!"),
719            Message::user("What is the weather?"),
720        ];
721
722        let path = session_dir.join("transcript.jsonl");
723        let mut file = std::fs::File::create(&path).unwrap();
724        for msg in &messages {
725            let saved = SavedMessage::from_message(msg.clone());
726            let line = serde_json::to_string(&saved).unwrap();
727            writeln!(file, "{line}").unwrap();
728        }
729        drop(file);
730
731        // Read back
732        let content = std::fs::read_to_string(&path).unwrap();
733        let loaded: Vec<SavedMessage> = content
734            .lines()
735            .filter(|l| !l.trim().is_empty())
736            .map(|l| serde_json::from_str(l).unwrap())
737            .collect();
738
739        assert_eq!(loaded.len(), 3);
740        assert_eq!(loaded[0].message.content, "Hello");
741        assert_eq!(loaded[1].message.content, "Hi there!");
742        assert_eq!(loaded[2].message.content, "What is the weather?");
743
744        // Verify content-hash IDs (ACP-S1-02)
745        assert_eq!(loaded[0].id, sha256_first_12("Hello"));
746        assert_eq!(loaded[1].id, sha256_first_12("Hi there!"));
747        assert_eq!(loaded[2].id, sha256_first_12("What is the weather?"));
748    }
749
750    #[test]
751    fn saved_message_id_matches_content_hash() {
752        let msg = Message::user("test content");
753        let saved = SavedMessage::from_message(msg.clone());
754        assert_eq!(saved.id, sha256_first_12("test content"));
755    }
756
757    #[test]
758    fn same_content_same_id() {
759        let a = SavedMessage::from_message(Message::user("hello"));
760        let b = SavedMessage::from_message(Message::user("hello"));
761        assert_eq!(a.id, b.id);
762    }
763
764    #[test]
765    fn different_content_different_id() {
766        let a = SavedMessage::from_message(Message::user("hello"));
767        let b = SavedMessage::from_message(Message::user("world"));
768        assert_ne!(a.id, b.id);
769    }
770
771    #[test]
772    fn save_and_load_metadata_roundtrip() {
773        let tmp = tempfile::TempDir::new().unwrap();
774        let session_dir = tmp.path().join("test-sess-meta");
775        std::fs::create_dir_all(&session_dir).unwrap();
776
777        let metadata = SessionMetadata {
778            session_id: "test-sess-meta".to_string(),
779            cwd: PathBuf::from("/tmp"),
780            turn: 5,
781            system_prompt: Some("Be helpful.".to_string()),
782            mcp_servers: None,
783        };
784
785        let path = session_dir.join("metadata.json");
786        let json = serde_json::to_string_pretty(&metadata).unwrap();
787        std::fs::write(&path, json).unwrap();
788
789        // Read back
790        let content = std::fs::read_to_string(&path).unwrap();
791        let loaded: SessionMetadata = serde_json::from_str(&content).unwrap();
792        assert_eq!(loaded.session_id, "test-sess-meta");
793        assert_eq!(loaded.turn, 5);
794        assert_eq!(loaded.system_prompt, Some("Be helpful.".to_string()));
795    }
796
797    #[test]
798    fn corrupt_transcript_returns_error() {
799        let tmp = tempfile::TempDir::new().unwrap();
800        let session_dir = tmp.path().join("bad-sess");
801        std::fs::create_dir_all(&session_dir).unwrap();
802
803        // Write corrupt data
804        let path = session_dir.join("transcript.jsonl");
805        std::fs::write(&path, "not valid json\n").unwrap();
806
807        // Use a manager with the same persistence dir to test load
808        let _mgr = AcpSessionManager::new();
809        // The manager has no persistence dir, so we test the raw read
810        let content = std::fs::read_to_string(&path).unwrap();
811        let result: Result<SavedMessage, _> = serde_json::from_str(content.trim());
812        assert!(result.is_err());
813    }
814
815    // ── summarize_transcript tests (ACP-S1-04) ─────────────────────
816
817    #[test]
818    fn summarize_empty_transcript() {
819        let ctx = summarize_transcript(&[]);
820        assert!(ctx.summary.contains("No accomplishments"));
821        assert_eq!(ctx.message_count, 0);
822    }
823
824    #[test]
825    fn summarize_with_messages() {
826        let messages = vec![
827            Message::user("What is the capital of France?"),
828            Message::assistant("The capital of France is Paris."),
829            Message::user("Tell me more about Paris."),
830        ];
831        let ctx = summarize_transcript(&messages);
832        assert!(ctx.summary.contains("capital of France"));
833        assert!(ctx.summary.contains("Tell me more"));
834        assert_eq!(ctx.message_count, 3);
835    }
836
837    #[test]
838    fn summarize_with_tool_errors_detects_blockers() {
839        let messages = vec![
840            Message::user("List the files"),
841            Message::assistant("Let me check the directory."),
842            Message::tool_result("tc-1", "error: permission denied"),
843        ];
844        let ctx = summarize_transcript(&messages);
845        assert!(ctx.summary.contains("blocker"));
846        assert_eq!(ctx.message_count, 3);
847    }
848
849    // ── Compaction hints tests (ACP-S1-05) ─────────────────────────
850
851    #[test]
852    fn compaction_hints_all_compressible_when_threshold_zero() {
853        let mut messages = Vec::new();
854        for i in 0..5 {
855            let msg = SavedMessage::from_message(Message::user(format!("msg {i}")));
856            messages.push(msg);
857        }
858
859        let hints = compute_compaction_hints(&messages, 0);
860        assert_eq!(hints.len(), 5);
861        for hint in &hints {
862            assert!(
863                hint.compressible,
864                "all should be compressible with threshold 0"
865            );
866        }
867    }
868
869    #[test]
870    fn compaction_hints_recency_protected() {
871        let mut messages = Vec::new();
872        for i in 0..10 {
873            let msg = SavedMessage::from_message(Message::user(format!("msg {i}")));
874            messages.push(msg);
875        }
876
877        // threshold=2: last 2 messages are NOT compressible
878        let hints = compute_compaction_hints(&messages, 2);
879        assert_eq!(hints.len(), 10);
880
881        // Messages 0-7 (indices < 10-2=8) are compressible
882        for (i, hint) in hints.iter().enumerate().take(8) {
883            assert!(hint.compressible, "msg {i} should be compressible");
884        }
885        // Messages 8-9 are NOT compressible
886        assert!(!hints[8].compressible, "msg 8 should NOT be compressible");
887        assert!(!hints[9].compressible, "msg 9 should NOT be compressible");
888    }
889
890    #[test]
891    fn compaction_hints_configurable_threshold() {
892        let mut messages = Vec::new();
893        for i in 0..10 {
894            let msg = SavedMessage::from_message(Message::user(format!("msg {i}")));
895            messages.push(msg);
896        }
897
898        let hints = compute_compaction_hints(&messages, 5);
899        // Messages 0-4 (indices < 10-5=5) are compressible
900        for (i, hint) in hints.iter().enumerate().take(5) {
901            assert!(hint.compressible, "msg {i} should be compressible");
902        }
903        // Messages 5-9 are NOT compressible
904        for (i, hint) in hints.iter().enumerate().skip(5) {
905            assert!(!hint.compressible, "msg {i} should NOT be compressible");
906        }
907    }
908
909    #[test]
910    fn apply_compaction_hints_sets_compressible() {
911        let mut messages = vec![
912            SavedMessage::from_message(Message::user("old")),
913            SavedMessage::from_message(Message::user("new")),
914        ];
915        let hints = vec![
916            CompactionHint {
917                turn_index: 0,
918                compressible: true,
919            },
920            CompactionHint {
921                turn_index: 1,
922                compressible: false,
923            },
924        ];
925        apply_compaction_hints(&mut messages, &hints);
926        assert!(messages[0].compressible);
927        assert!(!messages[1].compressible);
928    }
929
930    // ── S1-16: Arc<CancellationToken> type assertion test ──────────
931
932    /// Compile-time assertion: refresh_cancel_token returns Arc<CancellationToken>.
933    #[test]
934    fn cancel_token_is_arc_type() {
935        // This test verifies the return type of refresh_cancel_token.
936        // It does not need a real AgentRuntime — it just checks the type.
937        // The type annotation forces the compiler to verify the Arc<CancellationToken>.
938        let _type_check: Arc<tokio_util::sync::CancellationToken> =
939            Arc::new(tokio_util::sync::CancellationToken::new());
940        // If the test compiles, the type is correct.
941    }
942
943    #[test]
944    fn acp_session_field_cancel_token_is_arc() {
945        // Verify that the AcpSession struct field is Arc<CancellationToken>
946        // by construction: we can check the type through a function.
947        fn assert_arc_cancel_token(t: &Arc<tokio_util::sync::CancellationToken>) {
948            assert!(!t.is_cancelled());
949        }
950        let token = Arc::new(tokio_util::sync::CancellationToken::new());
951        assert_arc_cancel_token(&token);
952    }
953
954    /// Verify that dropping an AcpSession does not cancel the token
955    /// if other Arc clones exist.
956    #[tokio::test]
957    async fn drop_acp_session_does_not_cancel_token() {
958        // We need a runtime just for verification — create a minimal one
959        use crate::llm::MockProvider;
960        let llm = Arc::new(MockProvider::new(vec![]));
961        let runtime = AgentRuntime::builder()
962            .llm(llm)
963            .build()
964            .expect("build runtime");
965
966        let shared_token = Arc::new(tokio_util::sync::CancellationToken::new());
967        let clone_for_drop = shared_token.clone();
968
969        {
970            let _session = AcpSession {
971                runtime,
972                cwd: PathBuf::from("/tmp"),
973                turn: 0,
974                session_id: "test".to_string(),
975                transcript: vec![],
976                cancel_token: clone_for_drop,
977                system_prompt: None,
978                mcp_servers: None,
979                client_capabilities: None,
980                acp_state: Arc::new(Mutex::new(AcpClientFsState::new())),
981                mcp_child_pids: Vec::new(),
982            };
983            // Session drops here — but shared_token should NOT be cancelled
984        }
985
986        assert!(
987            !shared_token.is_cancelled(),
988            "dropping AcpSession must not cancel token if other Arc clones exist"
989        );
990    }
991}