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<&crate::tools::output::DeltaSender>,
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                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                    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<&crate::tools::output::DeltaSender>,
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.command.unwrap_or_else(|| {
322            std::env::var("SHELL").unwrap_or_else(|_| {
323                if cfg!(windows) {
324                    // No $SHELL convention on Windows; PowerShell is the
325                    // native interactive default and always present.
326                    "powershell".into()
327                } else {
328                    "bash".into()
329                }
330            })
331        });
332        let rows = opts.rows.unwrap_or(self.config.default_rows);
333        let cols = opts.cols.unwrap_or(self.config.default_cols);
334
335        let idle_timeout = opts
336            .idle_timeout
337            .map(Duration::from_secs)
338            .unwrap_or(self.config.idle_timeout);
339
340        // --- Spawn PTY ---
341        let mut pty = PtyHandle::spawn(
342            &command,
343            opts.working_directory.as_deref(),
344            opts.env,
345            rows,
346            cols,
347        )?;
348
349        // --- Build readiness detector (with per-session overrides) ---
350        let silence_ms = opts
351            .readiness_timeout_ms
352            .unwrap_or(self.config.readiness_timeout_ms);
353        let detector = ReadinessDetector::new(
354            super::readiness::ReadinessStrategy::Hybrid,
355            &self.config.prompt_patterns,
356            silence_ms,
357            self.config.max_readiness_timeout_ms,
358        );
359
360        // --- Wait for initial output ---
361        // Give the process a moment to start producing output before polling.
362        // Without this, the silence timeout can fire before the process has
363        // had time to print anything (e.g. Python startup, shell rc files).
364        tokio::time::sleep(Duration::from_millis(200)).await;
365        let (initial_output, status_str) = wait_for_output(
366            &mut pty,
367            &detector,
368            opts.readiness_timeout_ms,
369            tx_delta,
370            max_output,
371        )
372        .await;
373
374        let now = Instant::now();
375        let status = if status_str.starts_with("exited") {
376            SessionStatus::Exited(None)
377        } else {
378            SessionStatus::Active
379        };
380
381        let session = ShellSession {
382            pty,
383            detector,
384            created_at: now,
385            last_active: now,
386            idle_timeout,
387            status,
388        };
389
390        // --- Insert into map ---
391        {
392            let mut sessions = self
393                .sessions
394                .lock()
395                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
396            sessions.insert(id.clone(), session);
397        }
398
399        Ok((id, initial_output, status_str))
400    }
401
402    /// Send input to an active session and return the output produced.
403    pub async fn send_input(
404        &self,
405        id: &str,
406        input: &str,
407        timeout_ms: Option<u64>,
408        tx_delta: Option<&crate::tools::output::DeltaSender>,
409        max_output: usize,
410    ) -> Result<SendResult> {
411        // --- Remove session from map (release lock before I/O) ---
412        let mut session = {
413            let mut sessions = self
414                .sessions
415                .lock()
416                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
417            sessions.remove(id).ok_or_else(|| {
418                RuntimeError::Tool(format!(
419                    "session {id} not found — it may have been closed, reaped, or is currently in use by another call"
420                ))
421            })?
422        };
423
424        // --- Reject if not active ---
425        if session.status != SessionStatus::Active {
426            // Reinsert so it can still be closed/inspected.
427            let s_str = status_string(&session.status);
428            let mut sessions = self
429                .sessions
430                .lock()
431                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
432            sessions.insert(id.to_string(), session);
433            return Err(RuntimeError::Tool(format!(
434                "session {id} is not active (status: {s_str})"
435            )));
436        }
437
438        // --- Write input (with escape sequence processing) ---
439        let processed = process_input_escapes(input);
440        session.pty.write(processed.as_bytes())?;
441
442        // --- Wait for output ---
443        let (output, status_str) = wait_for_output(
444            &mut session.pty,
445            &session.detector,
446            timeout_ms,
447            tx_delta,
448            max_output,
449        )
450        .await;
451
452        // --- Update metadata ---
453        session.last_active = Instant::now();
454        if !session.pty.is_alive() {
455            session.status = SessionStatus::Exited(None);
456        }
457
458        let result = SendResult {
459            output,
460            status: status_str,
461        };
462
463        // --- Reinsert into map ---
464        {
465            let mut sessions = self
466                .sessions
467                .lock()
468                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
469            sessions.insert(id.to_string(), session);
470        }
471
472        Ok(result)
473    }
474
475    /// Close a session, returning any final output.
476    ///
477    /// Idempotent — closing a non-existent session returns `Ok("")`.
478    pub async fn close_session(&self, id: &str) -> Result<String> {
479        let mut session = {
480            let mut sessions = self
481                .sessions
482                .lock()
483                .map_err(|e| RuntimeError::Tool(format!("session lock poisoned: {e}")))?;
484            match sessions.remove(id) {
485                Some(s) => s,
486                None => return Ok(String::new()),
487            }
488        };
489
490        // Read remaining output with a short timeout.
491        let remaining = session
492            .pty
493            .try_read_output(Duration::from_millis(100))
494            .await;
495        let final_output = if remaining.is_empty() {
496            String::new()
497        } else {
498            strip_ansi(&String::from_utf8_lossy(&remaining))
499        };
500
501        // PtyHandle::drop will kill the child process.
502        drop(session);
503
504        Ok(final_output)
505    }
506
507    /// Reap sessions that have been idle beyond their timeout.
508    ///
509    /// Returns the IDs of sessions that were removed.
510    pub fn reap_idle(&self) -> Vec<String> {
511        let mut sessions = match self.sessions.lock() {
512            Ok(s) => s,
513            Err(e) => {
514                tracing::error!("session lock poisoned: {e}");
515                return Vec::new();
516            }
517        };
518
519        let grace_period = Duration::from_secs(5);
520
521        let ids_to_reap: Vec<String> = sessions
522            .iter()
523            .filter(|(_, s)| {
524                let elapsed = s.last_active.elapsed();
525                elapsed > s.idle_timeout && elapsed > grace_period
526            })
527            .map(|(id, _)| id.clone())
528            .collect();
529
530        for id in &ids_to_reap {
531            sessions.remove(id);
532            // Dropped sessions clean up via PtyHandle::drop.
533        }
534
535        ids_to_reap
536    }
537
538    /// Shutdown all sessions immediately.
539    pub fn shutdown_all(&self) {
540        match self.sessions.lock() {
541            Ok(mut sessions) => {
542                sessions.drain();
543                // All PtyHandles dropped — children killed.
544            }
545            Err(e) => {
546                tracing::error!("session lock poisoned: {e}");
547            }
548        }
549    }
550
551    /// Number of sessions currently in the map.
552    pub fn active_count(&self) -> usize {
553        match self.sessions.lock() {
554            Ok(s) => s.len(),
555            Err(e) => {
556                tracing::error!("session lock poisoned: {e}");
557                0
558            }
559        }
560    }
561
562    /// Snapshot of all sessions.
563    pub fn list_sessions(&self) -> Vec<ShellSessionInfo> {
564        match self.sessions.lock() {
565            Ok(sessions) => sessions
566                .iter()
567                .map(|(id, s)| ShellSessionInfo {
568                    id: id.clone(),
569                    status: s.status.clone(),
570                    created_at: s.created_at,
571                    last_active: s.last_active,
572                })
573                .collect(),
574            Err(e) => {
575                tracing::error!("session lock poisoned: {e}");
576                Vec::new()
577            }
578        }
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Drop implementation — cleanup on drop
584// ---------------------------------------------------------------------------
585
586impl Drop for SessionManager {
587    fn drop(&mut self) {
588        self.shutdown_all();
589    }
590}
591
592// ---------------------------------------------------------------------------
593// Background reaper
594// ---------------------------------------------------------------------------
595
596/// Spawn a background task that periodically reaps idle sessions.
597///
598/// The reaper runs every 30 seconds, checking for sessions whose last activity
599/// exceeds their configured idle timeout. Returns immediately — the task runs
600/// until the `CancellationToken` is canceled (or the process exits).
601pub fn start_reaper(
602    manager: Arc<SessionManager>,
603    cancel: tokio_util::sync::CancellationToken,
604) -> tokio::task::JoinHandle<()> {
605    tokio::spawn(async move {
606        let interval = Duration::from_secs(30);
607        loop {
608            tokio::select! {
609                _ = cancel.cancelled() => break,
610                _ = tokio::time::sleep(interval) => {
611                    let reaped = manager.reap_idle();
612                    for id in &reaped {
613                        tracing::info!(session_id = %id, "reaped idle shell session");
614                    }
615                }
616            }
617        }
618    })
619}
620
621// ---------------------------------------------------------------------------
622// Tests
623// ---------------------------------------------------------------------------
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    fn default_manager() -> Arc<SessionManager> {
630        SessionManager::new(ShellConfig::default())
631    }
632
633    fn opts_for(command: &str) -> SessionOpts {
634        SessionOpts {
635            command: Some(command.to_string()),
636            working_directory: None,
637            env: HashMap::new(),
638            rows: None,
639            cols: None,
640            readiness_timeout_ms: None,
641            idle_timeout: None,
642        }
643    }
644
645    // 1. Create session with `echo hello` → output contains "hello"
646    #[tokio::test]
647    async fn test_create_session_echo_hello() {
648        let mgr = default_manager();
649        let (id, output, _status) = mgr
650            .create_session(opts_for("echo hello"), None, 256 * 1024)
651            .await
652            .expect("failed to create session");
653
654        assert!(id.starts_with("shell_"));
655        assert!(
656            output.contains("hello"),
657            "expected 'hello' in output, got: {output:?}"
658        );
659    }
660
661    // 2. Create bash session, send `echo test\n` → output contains "test"
662    #[tokio::test]
663    async fn test_send_input_echo() {
664        let mgr = default_manager();
665        let (id, _initial, _status) = mgr
666            .create_session(opts_for("bash"), None, 256 * 1024)
667            .await
668            .expect("failed to create session");
669
670        let result = mgr
671            .send_input(&id, "echo test\n", None, None, 256 * 1024)
672            .await
673            .expect("failed to send input");
674
675        assert!(
676            result.output.contains("test"),
677            "expected 'test' in output, got: {:?}",
678            result.output
679        );
680
681        // Clean up
682        let _ = mgr.close_session(&id).await;
683    }
684
685    // 3. Close session → idempotent (close twice is fine)
686    #[tokio::test]
687    async fn test_close_session_idempotent() {
688        let mgr = default_manager();
689        let (id, _, _status) = mgr
690            .create_session(opts_for("bash"), None, 256 * 1024)
691            .await
692            .expect("failed to create session");
693
694        let result1 = mgr.close_session(&id).await;
695        assert!(result1.is_ok(), "first close should succeed");
696
697        let result2 = mgr.close_session(&id).await;
698        assert!(
699            result2.is_ok(),
700            "second close should also succeed (idempotent)"
701        );
702        assert_eq!(result2.unwrap(), "", "second close returns empty string");
703    }
704
705    // 4. Max sessions limit → error on exceeding
706    #[tokio::test]
707    async fn test_max_sessions_limit() {
708        let config = ShellConfig {
709            max_sessions: 2,
710            ..Default::default()
711        };
712        let mgr = SessionManager::new(config);
713
714        let (id1, _, _s) = mgr
715            .create_session(opts_for("bash"), None, 256 * 1024)
716            .await
717            .expect("session 1");
718        let (id2, _, _s) = mgr
719            .create_session(opts_for("bash"), None, 256 * 1024)
720            .await
721            .expect("session 2");
722
723        let result = mgr.create_session(opts_for("bash"), None, 256 * 1024).await;
724        assert!(result.is_err(), "third session should fail");
725        let err_msg = format!("{}", result.unwrap_err());
726        assert!(
727            err_msg.contains("maximum session limit"),
728            "error should mention limit, got: {err_msg}"
729        );
730
731        // Clean up
732        let _ = mgr.close_session(&id1).await;
733        let _ = mgr.close_session(&id2).await;
734    }
735
736    // 5. Session not found → error
737    #[tokio::test]
738    async fn test_session_not_found() {
739        let mgr = default_manager();
740        let result = mgr
741            .send_input("shell_99", "hello\n", None, None, 256 * 1024)
742            .await;
743        assert!(result.is_err(), "send to non-existent session should fail");
744        let err_msg = format!("{}", result.unwrap_err());
745        assert!(
746            err_msg.contains("not found"),
747            "error should mention 'not found', got: {err_msg}"
748        );
749    }
750
751    // ── normalize_output tests ──
752
753    #[test]
754    fn test_normalize_output_crlf() {
755        assert_eq!(normalize_output("hello\r\nworld\r\n"), "hello\nworld\n");
756    }
757
758    #[test]
759    fn test_normalize_output_lone_cr() {
760        assert_eq!(normalize_output("abc\rdef"), "abcdef");
761    }
762
763    // ── process_input_escapes tests ──
764
765    #[test]
766    fn test_escape_newline() {
767        assert_eq!(process_input_escapes(r"hello\n"), "hello\n");
768    }
769
770    #[test]
771    fn test_escape_tab() {
772        assert_eq!(process_input_escapes(r"a\tb"), "a\tb");
773    }
774
775    #[test]
776    fn test_escape_ctrl_c() {
777        assert_eq!(process_input_escapes(r"\x03"), "\x03");
778    }
779
780    #[test]
781    fn test_escape_ctrl_d() {
782        assert_eq!(process_input_escapes(r"\x04"), "\x04");
783    }
784
785    #[test]
786    fn test_escape_literal_backslash() {
787        assert_eq!(process_input_escapes(r"a\\b"), "a\\b");
788    }
789
790    #[test]
791    fn test_escape_real_newline_passthrough() {
792        // If the model sends an actual newline (JSON parsed correctly), it passes through
793        assert_eq!(process_input_escapes("hello\n"), "hello\n");
794    }
795
796    #[test]
797    fn test_escape_mixed() {
798        assert_eq!(process_input_escapes(r"ls -la\n"), "ls -la\n");
799        assert_eq!(process_input_escapes(r"124\n"), "124\n");
800    }
801
802    #[test]
803    fn test_escape_unknown_sequence() {
804        // Unknown escapes pass through literally
805        assert_eq!(process_input_escapes(r"\q"), "\\q");
806    }
807
808    #[test]
809    fn test_escape_hex_partial() {
810        // Incomplete hex — pass through
811        assert_eq!(process_input_escapes(r"\xZZ"), "\\xZZ");
812    }
813
814    #[test]
815    fn test_escape_bell() {
816        assert_eq!(process_input_escapes(r"\a"), "\x07");
817    }
818
819    #[test]
820    fn test_escape_backspace() {
821        assert_eq!(process_input_escapes(r"\b"), "\x08");
822    }
823
824    #[test]
825    fn test_escape_null() {
826        assert_eq!(process_input_escapes(r"\0"), "\0");
827    }
828
829    #[test]
830    fn test_escape_esc_blocked() {
831        // \e should be blocked (produces empty string for that escape)
832        assert_eq!(process_input_escapes(r"\e"), "");
833    }
834
835    #[test]
836    fn test_escape_hex_1b_blocked() {
837        // \x1b should be blocked
838        assert_eq!(process_input_escapes(r"\x1b"), "");
839    }
840
841    #[test]
842    fn test_escape_hex_high_byte_blocked() {
843        // \x80 and above should be blocked
844        assert_eq!(process_input_escapes(r"\x80"), "");
845        assert_eq!(process_input_escapes(r"\xff"), "");
846    }
847
848    #[test]
849    fn test_escape_hex_del_allowed() {
850        // \x7f (DEL) should be allowed
851        assert_eq!(process_input_escapes(r"\x7f"), "\x7f");
852    }
853}