Skip to main content

agent_engine/tools/shell/
session.rs

1//! Session manager — owns and manages active shell sessions.
2//!
3//! `SessionManager` is the core lifecycle engine for interactive PTY sessions.
4//! It provides thread-safe creation, I/O, and cleanup of `ShellSession`s, each
5//! backed by a `PtyHandle` and a `ReadinessDetector`.
6//!
7//! Design invariants:
8//! - The `Mutex` is held **only** for HashMap insert/remove — never during I/O.
9//! - Sessions are removed from the map before I/O and reinserted after, so the
10//!   lock is never contended by blocking reads/writes.
11//! - ANSI escape sequences are stripped from all output before returning.
12
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU32, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use crate::tools::strip_ansi;
19use crate::{Result, RuntimeError};
20
21use super::config::ShellConfig;
22use super::pty::PtyHandle;
23use crate::tools::utf8_stream::Utf8StreamDecoder;
24use super::readiness::{ReadinessDetector, ReadinessResult, ReadinessStrategy};
25
26// ---------------------------------------------------------------------------
27// Public types
28// ---------------------------------------------------------------------------
29
30/// Thread-safe manager for all active shell sessions.
31pub struct SessionManager {
32    sessions: Mutex<HashMap<String, ShellSession>>,
33    config: ShellConfig,
34    next_id: AtomicU32,
35}
36
37/// A single shell session — PTY handle, readiness detector, and metadata.
38struct ShellSession {
39    pty: PtyHandle,
40    /// Holds a character split across PTY reads until its last byte arrives
41    /// (#286). Lives with the session so a split across two calls survives.
42    decoder: Utf8StreamDecoder,
43    detector: ReadinessDetector,
44    created_at: Instant,
45    last_active: Instant,
46    idle_timeout: Duration,
47    status: SessionStatus,
48}
49
50/// Lifecycle status of a session.
51#[derive(Debug, Clone, PartialEq)]
52pub enum SessionStatus {
53    Active,
54    Exited(Option<i32>),
55    Closed,
56}
57
58/// Options for creating a new session.
59pub struct SessionOpts {
60    pub command: Option<String>,
61    pub working_directory: Option<String>,
62    /// The session's env, as its other tools spawn with it (forwarded
63    /// secrets merged in): the base `env` overrides. `None` = this
64    /// process's env (in-process).
65    pub base_env: Option<Vec<(String, String)>>,
66    pub env: HashMap<String, String>,
67    pub rows: Option<u16>,
68    pub cols: Option<u16>,
69    pub readiness_timeout_ms: Option<u64>,
70    pub idle_timeout: Option<u64>,
71}
72
73/// Result of sending input to a session.
74#[derive(Debug)]
75pub struct SendResult {
76    pub output: String,
77    /// One of: `"active"`, `"exited"`, `"exited(N)"`, `"timeout"`
78    pub status: String,
79}
80
81/// Snapshot of session metadata (no mutable borrows needed).
82pub struct ShellSessionInfo {
83    pub id: String,
84    pub status: SessionStatus,
85    pub created_at: Instant,
86    pub last_active: Instant,
87}
88
89// ---------------------------------------------------------------------------
90// Core readiness polling loop
91// ---------------------------------------------------------------------------
92
93/// Normalize PTY output: strip ANSI escapes and convert \r\n → \n.
94fn normalize_output(raw: &str) -> String {
95    strip_ansi(raw).replace("\r\n", "\n").replace('\r', "")
96}
97
98/// Process escape sequences in input strings from the model.
99///
100/// The model sometimes sends literal two-character sequences like `\n` instead
101/// of actual control characters. This function converts common literal escapes
102/// to their real byte values as a defense-in-depth measure.
103fn process_input_escapes(input: &str) -> String {
104    let mut result = String::with_capacity(input.len());
105    let mut chars = input.chars().peekable();
106
107    while let Some(ch) = chars.next() {
108        if ch == '\\' {
109            match chars.peek() {
110                Some('n') => {
111                    chars.next();
112                    result.push('\n');
113                }
114                Some('r') => {
115                    chars.next();
116                    result.push('\r');
117                }
118                Some('t') => {
119                    chars.next();
120                    result.push('\t');
121                }
122                Some('\\') => {
123                    chars.next();
124                    result.push('\\');
125                }
126                Some('a') => {
127                    chars.next();
128                    result.push('\x07');
129                } // bell
130                Some('b') => {
131                    chars.next();
132                    result.push('\x08');
133                } // backspace
134                Some('0') => {
135                    chars.next();
136                    result.push('\0');
137                } // null
138                Some('e') => {
139                    chars.next();
140                    tracing::warn!("blocked \\e escape sequence (raw ESC) in shell input");
141                }
142                Some('x') => {
143                    chars.next(); // consume 'x'
144                    let mut hex = String::new();
145                    for _ in 0..2 {
146                        if let Some(&c) = chars.peek() {
147                            if c.is_ascii_hexdigit() {
148                                hex.push(c);
149                                chars.next();
150                            } else {
151                                break;
152                            }
153                        }
154                    }
155                    if let Ok(byte) = u8::from_str_radix(&hex, 16) {
156                        if byte == 0x1b {
157                            // Block ESC (ANSI escape initiator)
158                            tracing::warn!(
159                                "blocked \\x1b escape sequence (raw ESC) in shell input"
160                            );
161                        } else if byte >= 0x80 {
162                            // Block high bytes
163                            tracing::warn!("blocked \\x{hex:} high byte (>= 0x80) in shell input");
164                        } else {
165                            // Allow: 0x00-0x1a, 0x1c-0x1f (control chars except ESC), 0x20-0x7f
166                            result.push(byte as char);
167                        }
168                    } else {
169                        // Failed to parse — emit the original characters
170                        result.push('\\');
171                        result.push('x');
172                        result.push_str(&hex);
173                    }
174                }
175                _ => {
176                    // Unknown escape — pass through literally
177                    result.push(ch);
178                }
179            }
180        } else {
181            result.push(ch);
182        }
183    }
184    result
185}
186
187/// Format a `SessionStatus` as a status string.
188fn status_string(status: &SessionStatus) -> String {
189    match status {
190        SessionStatus::Active => "active".into(),
191        SessionStatus::Exited(Some(code)) => format!("exited({code})"),
192        SessionStatus::Exited(None) => "exited".into(),
193        SessionStatus::Closed => "closed".into(),
194    }
195}
196
197/// Wait for output from the PTY until the readiness detector signals completion.
198///
199/// Returns `(normalized_output, status_string)` where status is one of
200/// `"active"`, `"exited"`, `"exited(N)"`, or `"timeout"`.
201async fn wait_for_output(
202    pty: &mut PtyHandle,
203    decoder: &mut Utf8StreamDecoder,
204    detector: &ReadinessDetector,
205    timeout_override: Option<u64>,
206    tx_delta: Option<&crate::tools::output::DeltaSender>,
207    max_output: usize,
208) -> (String, String) {
209    // If a timeout override is provided, build a temporary detector with that
210    // silence timeout instead of using the session's detector.
211    let override_detector;
212    let effective_detector = if let Some(ms) = timeout_override {
213        override_detector = ReadinessDetector::new(
214            ReadinessStrategy::Hybrid,
215            &[], // no prompt patterns — falls back to Timeout strategy
216            ms,
217            ms.saturating_mul(10).max(10_000), // reasonable max timeout
218        );
219        &override_detector
220    } else {
221        detector
222    };
223
224    let mut output = String::new();
225    let start = Instant::now();
226    let mut last_output_time = Instant::now();
227    let poll_interval = Duration::from_millis(50);
228
229    loop {
230        // Try reading from PTY (async — waits up to poll_interval for data).
231        let bytes = pty.try_read_output(poll_interval).await;
232
233        if !bytes.is_empty() {
234            let text = decoder.decode(&bytes);
235            output.push_str(&text);
236            last_output_time = Instant::now();
237
238            // Stream to TUI if requested (normalized)
239            if let Some(tx) = tx_delta {
240                tx.send(normalize_output(&text));
241            }
242        }
243
244        // Check if output exceeds the max size — truncate and return early.
245        if output.len() > max_output {
246            let mut trunc = max_output;
247            while trunc > 0 && !output.is_char_boundary(trunc) {
248                trunc -= 1;
249            }
250            output.truncate(trunc);
251            return (normalize_output(&output), "active".into());
252        }
253
254        // Check if process exited.
255        if !pty.is_alive() {
256            // Drain any remaining buffered output.
257            tokio::time::sleep(Duration::from_millis(50)).await;
258            let remaining = pty.try_read_output(Duration::from_millis(100)).await;
259            let mut remaining_text = decoder.decode(&remaining);
260            remaining_text.push_str(&decoder.finish());
261            if !remaining_text.is_empty() {
262                output.push_str(&remaining_text);
263
264                // Stream remaining output to TUI
265                if let Some(tx) = tx_delta {
266                    tx.send(normalize_output(&remaining_text));
267                }
268            }
269            // PtyHandle doesn't expose exit codes currently — use None
270            return (
271                normalize_output(&output),
272                status_string(&SessionStatus::Exited(None)),
273            );
274        }
275
276        // Evaluate readiness.
277        let silence_elapsed = last_output_time.elapsed();
278        let total_elapsed = start.elapsed();
279
280        match effective_detector.check(&output, silence_elapsed, total_elapsed) {
281            ReadinessResult::Ready => return (normalize_output(&output), "active".into()),
282            ReadinessResult::SilenceTimeout => return (normalize_output(&output), "active".into()),
283            ReadinessResult::MaxTimeout => return (normalize_output(&output), "timeout".into()),
284            ReadinessResult::Waiting => continue,
285        }
286    }
287}
288
289// ---------------------------------------------------------------------------
290// SessionManager implementation
291// ---------------------------------------------------------------------------
292
293impl SessionManager {
294    /// Create a new session manager backed by the given configuration.
295    pub fn new(config: ShellConfig) -> Arc<Self> {
296        Arc::new(Self {
297            sessions: Mutex::new(HashMap::new()),
298            config,
299            next_id: AtomicU32::new(0),
300        })
301    }
302
303    /// Create a new interactive shell session.
304    ///
305    /// Returns `(session_id, initial_output, status)` on success.
306    pub async fn create_session(
307        &self,
308        opts: SessionOpts,
309        tx_delta: Option<&crate::tools::output::DeltaSender>,
310        max_output: usize,
311    ) -> Result<(String, String, String)> {
312        self.create_session_cancellable(opts, tx_delta, max_output, None).await
313    }
314
315    pub(crate) async fn create_session_cancellable(
316        &self, opts: SessionOpts,
317        tx_delta: Option<&crate::tools::output::DeltaSender>, max_output: usize,
318        cancel: Option<&crate::CancellationToken>,
319    ) -> Result<(String, String, String)> {
320        // --- Check session limit ---
321        {
322            let sessions = self
323                .sessions
324                .lock()
325                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
326            if sessions.len() >= self.config.max_sessions {
327                return Err(RuntimeError::Tool(format!(
328                    "maximum session limit reached ({})",
329                    self.config.max_sessions
330                )));
331            }
332        }
333
334        // --- Generate ID (shell_01, shell_02, …) ---
335        let seq = self.next_id.fetch_add(1, Ordering::SeqCst) + 1;
336        let id = format!("shell_{:02}", seq);
337
338        // --- Resolve parameters with config defaults ---
339        let command = opts.command.unwrap_or_else(|| {
340            std::env::var("SHELL").unwrap_or_else(|_| {
341                if cfg!(windows) {
342                    // No $SHELL convention on Windows; PowerShell is the
343                    // native interactive default and always present.
344                    "powershell".into()
345                } else {
346                    "bash".into()
347                }
348            })
349        });
350        let rows = opts.rows.unwrap_or(self.config.default_rows);
351        let cols = opts.cols.unwrap_or(self.config.default_cols);
352
353        let idle_timeout = opts
354            .idle_timeout
355            .map(Duration::from_secs)
356            .unwrap_or(self.config.idle_timeout);
357
358        // --- Spawn PTY ---
359        if cancel.is_some_and(|c| c.is_cancelled()) {
360            return Err(RuntimeError::Tool("Shell canceled before launch".into()));
361        }
362        let probe_cwd = opts.working_directory.clone();
363        let probe_env = opts.env;
364        let base_env = opts.base_env;
365        // The worker prepares only the probe; it never spawns a PTY command.
366        let probe = tokio::task::spawn_blocking(move ||
367            PtyHandle::prepare_scope(probe_cwd.as_deref(), base_env.as_deref(), &probe_env));
368        let prepared = if let Some(cancel) = cancel {
369            tokio::select! {
370                biased;
371                _ = cancel.cancelled() => return Err(RuntimeError::Tool("Shell canceled before launch".into())),
372                result = probe => result,
373            }
374        } else { probe.await }.map_err(|e| RuntimeError::Tool(e.to_string()))?;
375        if cancel.is_some_and(|c| c.is_cancelled()) {
376            return Err(RuntimeError::Tool("Shell canceled before launch".into()));
377        }
378        let mut pty = PtyHandle::spawn_prepared(
379            &command, opts.working_directory.as_deref(), rows, cols, prepared,
380        )?;
381
382        // --- Build readiness detector (with per-session overrides) ---
383        let silence_ms = opts
384            .readiness_timeout_ms
385            .unwrap_or(self.config.readiness_timeout_ms);
386        let detector = ReadinessDetector::new(
387            super::readiness::ReadinessStrategy::Hybrid,
388            &self.config.prompt_patterns,
389            silence_ms,
390            self.config.max_readiness_timeout_ms,
391        );
392
393        // --- Wait for initial output ---
394        // Give the process a moment to start producing output before polling.
395        // Without this, the silence timeout can fire before the process has
396        // had time to print anything (e.g. Python startup, shell rc files).
397        tokio::time::sleep(Duration::from_millis(200)).await;
398        let mut decoder = Utf8StreamDecoder::new();
399        let (initial_output, status_str) = wait_for_output(
400            &mut pty,
401            &mut decoder,
402            &detector,
403            opts.readiness_timeout_ms,
404            tx_delta,
405            max_output,
406        )
407        .await;
408
409        let now = Instant::now();
410        let status = if status_str.starts_with("exited") {
411            SessionStatus::Exited(None)
412        } else {
413            SessionStatus::Active
414        };
415
416        let session = ShellSession {
417            pty,
418            decoder,
419            detector,
420            created_at: now,
421            last_active: now,
422            idle_timeout,
423            status,
424        };
425
426        // --- Insert into map ---
427        {
428            let mut sessions = self
429                .sessions
430                .lock()
431                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
432            sessions.insert(id.clone(), session);
433        }
434
435        Ok((id, initial_output, status_str))
436    }
437
438    /// Send input to an active session and return the output produced.
439    pub async fn send_input(
440        &self,
441        id: &str,
442        input: &str,
443        timeout_ms: Option<u64>,
444        tx_delta: Option<&crate::tools::output::DeltaSender>,
445        max_output: usize,
446    ) -> Result<SendResult> {
447        // --- Remove session from map (release lock before I/O) ---
448        let mut session = {
449            let mut sessions = self
450                .sessions
451                .lock()
452                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
453            sessions.remove(id).ok_or_else(|| {
454                RuntimeError::Tool(format!(
455                    "session {id} not found — it may have been closed, reaped, or is currently in use by another call"
456                ))
457            })?
458        };
459
460        // --- Reject if not active ---
461        if session.status != SessionStatus::Active {
462            // Reinsert so it can still be closed/inspected.
463            let s_str = status_string(&session.status);
464            let mut sessions = self
465                .sessions
466                .lock()
467                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
468            sessions.insert(id.to_string(), session);
469            return Err(RuntimeError::Tool(format!(
470                "session {id} is not active (status: {s_str})"
471            )));
472        }
473
474        // --- Write input (with escape sequence processing) ---
475        let processed = process_input_escapes(input);
476        session.pty.write(processed.as_bytes())?;
477
478        // --- Wait for output ---
479        let (output, status_str) = wait_for_output(
480            &mut session.pty,
481            &mut session.decoder,
482            &session.detector,
483            timeout_ms,
484            tx_delta,
485            max_output,
486        )
487        .await;
488
489        // --- Update metadata ---
490        session.last_active = Instant::now();
491        if !session.pty.is_alive() {
492            session.status = SessionStatus::Exited(None);
493        }
494
495        let result = SendResult {
496            output,
497            status: status_str,
498        };
499
500        // --- Reinsert into map ---
501        {
502            let mut sessions = self
503                .sessions
504                .lock()
505                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
506            sessions.insert(id.to_string(), session);
507        }
508
509        Ok(result)
510    }
511
512    /// Close a session, returning any final output.
513    ///
514    /// Idempotent — closing a non-existent session returns `Ok("")`.
515    pub async fn close_session(&self, id: &str) -> Result<String> {
516        let mut session = {
517            let mut sessions = self
518                .sessions
519                .lock()
520                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
521            match sessions.remove(id) {
522                Some(s) => s,
523                None => return Ok(String::new()),
524            }
525        };
526
527        // Read remaining output with a short timeout.
528        let remaining = session
529            .pty
530            .try_read_output(Duration::from_millis(100))
531            .await;
532        let mut final_text = session.decoder.decode(&remaining);
533        final_text.push_str(&session.decoder.finish());
534        let final_output = if final_text.is_empty() {
535            String::new()
536        } else {
537            strip_ansi(&final_text)
538        };
539
540        // PtyHandle::drop will kill the child process.
541        drop(session);
542
543        Ok(final_output)
544    }
545
546    /// Reap sessions that have been idle beyond their timeout.
547    ///
548    /// Returns the IDs of sessions that were removed.
549    pub fn reap_idle(&self) -> Vec<String> {
550        let mut sessions = match self.sessions.lock() {
551            Ok(s) => s,
552            Err(e) => {
553                tracing::error!("session lock poisoned: {e}");
554                return Vec::new();
555            }
556        };
557
558        let grace_period = Duration::from_secs(5);
559
560        let ids_to_reap: Vec<String> = sessions
561            .iter()
562            .filter(|(_, s)| {
563                let elapsed = s.last_active.elapsed();
564                elapsed > s.idle_timeout && elapsed > grace_period
565            })
566            .map(|(id, _)| id.clone())
567            .collect();
568
569        for id in &ids_to_reap {
570            sessions.remove(id);
571            // Dropped sessions clean up via PtyHandle::drop.
572        }
573
574        ids_to_reap
575    }
576
577    /// Shutdown all sessions immediately.
578    pub fn shutdown_all(&self) {
579        match self.sessions.lock() {
580            Ok(mut sessions) => {
581                sessions.drain();
582                // All PtyHandles dropped — children killed.
583            }
584            Err(e) => {
585                tracing::error!("session lock poisoned: {e}");
586            }
587        }
588    }
589
590    /// Number of sessions currently in the map.
591    pub fn active_count(&self) -> usize {
592        match self.sessions.lock() {
593            Ok(s) => s.len(),
594            Err(e) => {
595                tracing::error!("session lock poisoned: {e}");
596                0
597            }
598        }
599    }
600
601    /// Snapshot of all sessions.
602    pub fn list_sessions(&self) -> Vec<ShellSessionInfo> {
603        match self.sessions.lock() {
604            Ok(sessions) => sessions
605                .iter()
606                .map(|(id, s)| ShellSessionInfo {
607                    id: id.clone(),
608                    status: s.status.clone(),
609                    created_at: s.created_at,
610                    last_active: s.last_active,
611                })
612                .collect(),
613            Err(e) => {
614                tracing::error!("session lock poisoned: {e}");
615                Vec::new()
616            }
617        }
618    }
619}
620
621// ---------------------------------------------------------------------------
622// Drop implementation — cleanup on drop
623// ---------------------------------------------------------------------------
624
625impl Drop for SessionManager {
626    fn drop(&mut self) {
627        self.shutdown_all();
628    }
629}
630
631// ---------------------------------------------------------------------------
632// Background reaper
633// ---------------------------------------------------------------------------
634
635/// Spawn a background task that periodically reaps idle sessions.
636///
637/// The reaper runs every 30 seconds, checking for sessions whose last activity
638/// exceeds their configured idle timeout. Returns immediately — the task runs
639/// until the `CancellationToken` is canceled (or the process exits).
640pub fn start_reaper(
641    manager: Arc<SessionManager>,
642    cancel: tokio_util::sync::CancellationToken,
643) -> tokio::task::JoinHandle<()> {
644    tokio::spawn(async move {
645        let interval = Duration::from_secs(30);
646        loop {
647            tokio::select! {
648                _ = cancel.cancelled() => break,
649                _ = tokio::time::sleep(interval) => {
650                    let reaped = manager.reap_idle();
651                    for id in &reaped {
652                        tracing::info!(session_id = %id, "reaped idle shell session");
653                    }
654                }
655            }
656        }
657    })
658}
659
660// ---------------------------------------------------------------------------
661// Tests
662// ---------------------------------------------------------------------------
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    fn default_manager() -> Arc<SessionManager> {
669        SessionManager::new(ShellConfig::default())
670    }
671
672    fn opts_for(command: &str) -> SessionOpts {
673        SessionOpts {
674            command: Some(command.to_string()),
675            working_directory: None,
676            base_env: None,
677            // An interactive bash saves its history to $HOME/.bash_history on
678            // exit: the developer's real one, unless the test says otherwise.
679            env: HashMap::from([("HISTFILE".to_string(), "/dev/null".to_string())]),
680            rows: None,
681            cols: None,
682            readiness_timeout_ms: None,
683            idle_timeout: None,
684        }
685    }
686
687    #[tokio::test]
688    async fn canceled_creation_never_spawns_or_registers_a_shell() {
689        let mgr = default_manager();
690        let token = crate::CancellationToken::new();
691        token.cancel();
692        let error = mgr.create_session_cancellable(opts_for("echo should-not-run"), None,
693            256 * 1024, Some(&token)).await.unwrap_err().to_string();
694        assert!(error.contains("canceled before launch"), "{error}");
695        assert!(mgr.sessions.lock().unwrap().is_empty());
696    }
697
698    // 1. Create session with `echo hello` → output contains "hello"
699    #[tokio::test]
700    async fn test_create_session_echo_hello() {
701        let mgr = default_manager();
702        let (id, output, _status) = mgr
703            .create_session(opts_for("echo hello"), None, 256 * 1024)
704            .await
705            .expect("failed to create session");
706
707        assert!(id.starts_with("shell_"));
708        assert!(
709            output.contains("hello"),
710            "expected 'hello' in output, got: {output:?}"
711        );
712    }
713
714    // 2. Create bash session, send `echo test\n` → output contains "test"
715    #[tokio::test]
716    async fn test_send_input_echo() {
717        let mgr = default_manager();
718        let (id, _initial, _status) = mgr
719            .create_session(opts_for("bash"), None, 256 * 1024)
720            .await
721            .expect("failed to create session");
722
723        let result = mgr
724            .send_input(&id, "echo test\n", None, None, 256 * 1024)
725            .await
726            .expect("failed to send input");
727
728        assert!(
729            result.output.contains("test"),
730            "expected 'test' in output, got: {:?}",
731            result.output
732        );
733
734        // Clean up
735        let _ = mgr.close_session(&id).await;
736    }
737
738    /// Issue #286: a character whose bytes arrive in separate PTY polls must
739    /// come back whole. The sleep is longer than the 50 ms poll interval and
740    /// shorter than the 300 ms silence timeout.
741    #[tokio::test]
742    async fn test_character_split_across_polls_is_not_replaced() {
743        let mgr = default_manager();
744        let (id, _initial, _status) = mgr
745            .create_session(opts_for("bash"), None, 256 * 1024)
746            .await
747            .expect("failed to create session");
748
749        let result = mgr
750            .send_input(
751                &id,
752                "printf 'A\\342\\226'; sleep 0.15; printf '\\200B'; echo\n",
753                Some(2000),
754                None,
755                256 * 1024,
756            )
757            .await
758            .expect("failed to send input");
759        let _ = mgr.close_session(&id).await;
760
761        assert!(
762            !result.output.contains('\u{FFFD}'),
763            "split character became U+FFFD: {:?}",
764            result.output
765        );
766        assert!(result.output.contains("A\u{2580}B"), "{:?}", result.output);
767    }
768
769    // 3. Close session → idempotent (close twice is fine)
770    #[tokio::test]
771    async fn test_close_session_idempotent() {
772        let mgr = default_manager();
773        let (id, _, _status) = mgr
774            .create_session(opts_for("bash"), None, 256 * 1024)
775            .await
776            .expect("failed to create session");
777
778        let result1 = mgr.close_session(&id).await;
779        assert!(result1.is_ok(), "first close should succeed");
780
781        let result2 = mgr.close_session(&id).await;
782        assert!(
783            result2.is_ok(),
784            "second close should also succeed (idempotent)"
785        );
786        assert_eq!(result2.unwrap(), "", "second close returns empty string");
787    }
788
789    // 4. Max sessions limit → error on exceeding
790    #[tokio::test]
791    async fn test_max_sessions_limit() {
792        let config = ShellConfig {
793            max_sessions: 2,
794            ..Default::default()
795        };
796        let mgr = SessionManager::new(config);
797
798        let (id1, _, _s) = mgr
799            .create_session(opts_for("bash"), None, 256 * 1024)
800            .await
801            .expect("session 1");
802        let (id2, _, _s) = mgr
803            .create_session(opts_for("bash"), None, 256 * 1024)
804            .await
805            .expect("session 2");
806
807        let result = mgr.create_session(opts_for("bash"), None, 256 * 1024).await;
808        assert!(result.is_err(), "third session should fail");
809        let err_msg = format!("{}", result.unwrap_err());
810        assert!(
811            err_msg.contains("maximum session limit"),
812            "error should mention limit, got: {err_msg}"
813        );
814
815        // Clean up
816        let _ = mgr.close_session(&id1).await;
817        let _ = mgr.close_session(&id2).await;
818    }
819
820    // 5. Session not found → error
821    #[tokio::test]
822    async fn test_session_not_found() {
823        let mgr = default_manager();
824        let result = mgr
825            .send_input("shell_99", "hello\n", None, None, 256 * 1024)
826            .await;
827        assert!(result.is_err(), "send to non-existent session should fail");
828        let err_msg = format!("{}", result.unwrap_err());
829        assert!(
830            err_msg.contains("not found"),
831            "error should mention 'not found', got: {err_msg}"
832        );
833    }
834
835    // ── normalize_output tests ──
836
837    #[test]
838    fn test_normalize_output_crlf() {
839        assert_eq!(normalize_output("hello\r\nworld\r\n"), "hello\nworld\n");
840    }
841
842    #[test]
843    fn test_normalize_output_lone_cr() {
844        assert_eq!(normalize_output("abc\rdef"), "abcdef");
845    }
846
847    // ── process_input_escapes tests ──
848
849    #[test]
850    fn test_escape_newline() {
851        assert_eq!(process_input_escapes(r"hello\n"), "hello\n");
852    }
853
854    #[test]
855    fn test_escape_tab() {
856        assert_eq!(process_input_escapes(r"a\tb"), "a\tb");
857    }
858
859    #[test]
860    fn test_escape_ctrl_c() {
861        assert_eq!(process_input_escapes(r"\x03"), "\x03");
862    }
863
864    #[test]
865    fn test_escape_ctrl_d() {
866        assert_eq!(process_input_escapes(r"\x04"), "\x04");
867    }
868
869    #[test]
870    fn test_escape_literal_backslash() {
871        assert_eq!(process_input_escapes(r"a\\b"), "a\\b");
872    }
873
874    #[test]
875    fn test_escape_real_newline_passthrough() {
876        // If the model sends an actual newline (JSON parsed correctly), it passes through
877        assert_eq!(process_input_escapes("hello\n"), "hello\n");
878    }
879
880    #[test]
881    fn test_escape_mixed() {
882        assert_eq!(process_input_escapes(r"ls -la\n"), "ls -la\n");
883        assert_eq!(process_input_escapes(r"124\n"), "124\n");
884    }
885
886    #[test]
887    fn test_escape_unknown_sequence() {
888        // Unknown escapes pass through literally
889        assert_eq!(process_input_escapes(r"\q"), "\\q");
890    }
891
892    #[test]
893    fn test_escape_hex_partial() {
894        // Incomplete hex — pass through
895        assert_eq!(process_input_escapes(r"\xZZ"), "\\xZZ");
896    }
897
898    #[test]
899    fn test_escape_bell() {
900        assert_eq!(process_input_escapes(r"\a"), "\x07");
901    }
902
903    #[test]
904    fn test_escape_backspace() {
905        assert_eq!(process_input_escapes(r"\b"), "\x08");
906    }
907
908    #[test]
909    fn test_escape_null() {
910        assert_eq!(process_input_escapes(r"\0"), "\0");
911    }
912
913    #[test]
914    fn test_escape_esc_blocked() {
915        // \e should be blocked (produces empty string for that escape)
916        assert_eq!(process_input_escapes(r"\e"), "");
917    }
918
919    #[test]
920    fn test_escape_hex_1b_blocked() {
921        // \x1b should be blocked
922        assert_eq!(process_input_escapes(r"\x1b"), "");
923    }
924
925    #[test]
926    fn test_escape_hex_high_byte_blocked() {
927        // \x80 and above should be blocked
928        assert_eq!(process_input_escapes(r"\x80"), "");
929        assert_eq!(process_input_escapes(r"\xff"), "");
930    }
931
932    #[test]
933    fn test_escape_hex_del_allowed() {
934        // \x7f (DEL) should be allowed
935        assert_eq!(process_input_escapes(r"\x7f"), "\x7f");
936    }
937}