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