Skip to main content

gate4agent_types/
input.rs

1use crate::AgentId;
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5pub const TERMINAL_INPUT_CHUNK_MAX_BYTES: usize = 16 * 1024;
6pub const TERMINAL_INPUT_MAX_BYTES: usize = 16 * 1024 * 1024;
7pub const TERMINAL_BYTES_MAX_BYTES: usize = 64;
8pub const TERMINAL_SUBMIT_DELAY_MS: u64 = 500;
9pub const TERMINAL_WRITE_DELAY_MAX_MS: u64 = 5_000;
10pub const SEMANTIC_PROMPT_MAX_BYTES: usize = TERMINAL_INPUT_MAX_BYTES;
11pub const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
12pub const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~";
13
14#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
15pub enum PromptFraming {
16    BracketedPaste,
17    Literal,
18}
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21pub struct PromptPayload {
22    pub text: String,
23    pub framing: PromptFraming,
24}
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27pub struct AgentCommand {
28    pub agent_id: AgentId,
29    pub name: String,
30    pub arguments: Vec<String>,
31}
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct ShellCommand {
35    pub text: String,
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39pub struct TerminalText {
40    pub text: String,
41}
42
43#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44pub enum TerminalControl {
45    Interrupt,
46    EndOfFile,
47    ControlA,
48    ControlB,
49    ControlE,
50    ControlF,
51    ControlG,
52    ControlH,
53    ControlI,
54    ControlJ,
55    ControlK,
56    ControlL,
57    ControlM,
58    ControlN,
59    ControlO,
60    ControlP,
61    ControlQ,
62    ControlR,
63    ControlS,
64    ControlT,
65    ControlU,
66    ControlV,
67    ControlW,
68    ControlX,
69    ControlY,
70    ControlZ,
71    Enter,
72    LineFeed,
73    Escape,
74    Backspace,
75    Tab,
76    BackTab,
77    Insert,
78    Delete,
79    Home,
80    End,
81    PageUp,
82    PageDown,
83    ArrowUp,
84    ArrowDown,
85    ArrowRight,
86    ArrowLeft,
87    Function1,
88    Function2,
89    Function3,
90    Function4,
91    Function5,
92    Function6,
93    Function7,
94    Function8,
95    Function9,
96    Function10,
97    Function11,
98    Function12,
99}
100
101impl TerminalControl {
102    pub fn bytes(self) -> &'static [u8] {
103        match self {
104            Self::Interrupt => b"\x03",
105            Self::EndOfFile => b"\x04",
106            Self::ControlA => b"\x01",
107            Self::ControlB => b"\x02",
108            Self::ControlE => b"\x05",
109            Self::ControlF => b"\x06",
110            Self::ControlG => b"\x07",
111            Self::ControlH => b"\x08",
112            Self::ControlI => b"\x09",
113            Self::ControlJ => b"\x0a",
114            Self::ControlK => b"\x0b",
115            Self::ControlL => b"\x0c",
116            Self::ControlM => b"\x0d",
117            Self::ControlN => b"\x0e",
118            Self::ControlO => b"\x0f",
119            Self::ControlP => b"\x10",
120            Self::ControlQ => b"\x11",
121            Self::ControlR => b"\x12",
122            Self::ControlS => b"\x13",
123            Self::ControlT => b"\x14",
124            Self::ControlU => b"\x15",
125            Self::ControlV => b"\x16",
126            Self::ControlW => b"\x17",
127            Self::ControlX => b"\x18",
128            Self::ControlY => b"\x19",
129            Self::ControlZ => b"\x1a",
130            Self::Enter => b"\r",
131            Self::LineFeed => b"\n",
132            Self::Escape => b"\x1b",
133            Self::Backspace => b"\x08",
134            Self::Tab => b"\t",
135            Self::BackTab => b"\x1b[Z",
136            Self::Insert => b"\x1b[2~",
137            Self::Delete => b"\x1b[3~",
138            Self::Home => b"\x1b[H",
139            Self::End => b"\x1b[F",
140            Self::PageUp => b"\x1b[5~",
141            Self::PageDown => b"\x1b[6~",
142            Self::ArrowUp => b"\x1b[A",
143            Self::ArrowDown => b"\x1b[B",
144            Self::ArrowRight => b"\x1b[C",
145            Self::ArrowLeft => b"\x1b[D",
146            Self::Function1 => b"\x1bOP",
147            Self::Function2 => b"\x1bOQ",
148            Self::Function3 => b"\x1bOR",
149            Self::Function4 => b"\x1bOS",
150            Self::Function5 => b"\x1b[15~",
151            Self::Function6 => b"\x1b[17~",
152            Self::Function7 => b"\x1b[18~",
153            Self::Function8 => b"\x1b[19~",
154            Self::Function9 => b"\x1b[20~",
155            Self::Function10 => b"\x1b[21~",
156            Self::Function11 => b"\x1b[23~",
157            Self::Function12 => b"\x1b[24~",
158        }
159    }
160}
161
162#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
163pub enum InputAction {
164    InsertDraft(PromptPayload),
165    SubmitPrompt(PromptPayload),
166    AgentCommand(AgentCommand),
167    ShellCommand(ShellCommand),
168    TerminalText(TerminalText),
169    TerminalBytes(Vec<u8>),
170    TerminalControl(TerminalControl),
171}
172
173#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
174pub enum PreparedWriteKind {
175    Framing,
176    Data,
177    Submit,
178    Control,
179}
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
182pub struct PreparedWrite {
183    pub kind: PreparedWriteKind,
184    pub bytes: Vec<u8>,
185    /// Delay after the preceding write and before this write.
186    pub delay_before_ms: u64,
187}
188
189#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
190pub struct PreparedInput {
191    kind: PreparedInputKind,
192    writes: Vec<PreparedWrite>,
193}
194
195#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
196pub enum PreparedInputKind {
197    InsertDraft,
198    SubmitPrompt,
199    AgentCommand,
200    ShellCommand,
201    TerminalText,
202    TerminalBytes,
203    TerminalControl,
204}
205
206impl PreparedInput {
207    /// The operation semantic used to validate its readiness permit.
208    pub fn kind(&self) -> PreparedInputKind {
209        self.kind
210    }
211
212    /// Inspect the bounded write sequence without making it mutable or forgeable.
213    pub fn writes(&self) -> &[PreparedWrite] {
214        &self.writes
215    }
216
217    pub fn into_writes(self) -> Vec<PreparedWrite> {
218        self.writes
219    }
220}
221
222/// Converts an input action to bounded writes without touching a PTY.
223///
224/// Agent and shell commands require a dispatcher that can prove the current
225/// foreground target. Prompt paste and submit are emitted as separate writes.
226pub fn prepare_input(action: InputAction) -> Result<PreparedInput, InputPrepareError> {
227    prepare_input_with_limits(
228        action,
229        TERMINAL_INPUT_CHUNK_MAX_BYTES,
230        TERMINAL_INPUT_MAX_BYTES,
231    )
232}
233
234/// Validate and normalize a semantic prompt before it crosses a process or
235/// protocol boundary. Terminal controls are rendered inert consistently with
236/// PTY prompt preparation, but no terminal framing bytes are added.
237pub fn normalize_semantic_prompt(text: &str) -> Result<String, InputPrepareError> {
238    if text.len() > SEMANTIC_PROMPT_MAX_BYTES {
239        return Err(InputPrepareError::InputTooLarge {
240            bytes: text.len(),
241            max: SEMANTIC_PROMPT_MAX_BYTES,
242        });
243    }
244    Ok(sanitize_prompt_text(text))
245}
246
247pub fn prepare_input_with_limits(
248    action: InputAction,
249    max_chunk_bytes: usize,
250    max_total_bytes: usize,
251) -> Result<PreparedInput, InputPrepareError> {
252    if max_chunk_bytes == 0 {
253        return Err(InputPrepareError::ZeroChunkLimit);
254    }
255    match action {
256        InputAction::InsertDraft(payload) => {
257            prepare_prompt(payload, false, max_chunk_bytes, max_total_bytes)
258        }
259        InputAction::SubmitPrompt(payload) => {
260            prepare_prompt(payload, true, max_chunk_bytes, max_total_bytes)
261        }
262        InputAction::TerminalText(text) => {
263            if let Some((index, character)) = text
264                .text
265                .char_indices()
266                .find(|(_, character)| character.is_control())
267            {
268                return Err(InputPrepareError::ControlCharacterInTerminalText {
269                    index,
270                    codepoint: character as u32,
271                });
272            }
273            bounded_chunks(&text.text, max_chunk_bytes, max_total_bytes).map(|chunks| {
274                PreparedInput {
275                    kind: PreparedInputKind::TerminalText,
276                    writes: chunks
277                        .into_iter()
278                        .map(|bytes| PreparedWrite {
279                            kind: PreparedWriteKind::Data,
280                            bytes,
281                            delay_before_ms: 0,
282                        })
283                        .collect(),
284                }
285            })
286        }
287        InputAction::TerminalBytes(bytes) => {
288            if bytes.is_empty() {
289                return Err(InputPrepareError::EmptyTerminalBytes);
290            }
291            let max_bytes = TERMINAL_BYTES_MAX_BYTES
292                .min(max_chunk_bytes)
293                .min(max_total_bytes);
294            if bytes.len() > max_bytes {
295                return Err(InputPrepareError::InputTooLarge {
296                    bytes: bytes.len(),
297                    max: max_bytes,
298                });
299            }
300            Ok(PreparedInput {
301                kind: PreparedInputKind::TerminalBytes,
302                writes: vec![PreparedWrite {
303                    kind: PreparedWriteKind::Control,
304                    bytes,
305                    delay_before_ms: 0,
306                }],
307            })
308        }
309        InputAction::TerminalControl(control) => Ok(PreparedInput {
310            kind: PreparedInputKind::TerminalControl,
311            writes: vec![PreparedWrite {
312                kind: PreparedWriteKind::Control,
313                bytes: control.bytes().to_vec(),
314                delay_before_ms: 0,
315            }],
316        }),
317        InputAction::AgentCommand(_) => Err(InputPrepareError::AgentDispatcherRequired),
318        InputAction::ShellCommand(_) => Err(InputPrepareError::ShellDispatcherRequired),
319    }
320}
321
322/// Prepare a provider-native slash command after a dispatcher has selected the
323/// foreground agent. Arguments are TUI text segments, not shell arguments.
324pub fn prepare_agent_command(
325    command: AgentCommand,
326    target_agent: &AgentId,
327) -> Result<PreparedInput, InputPrepareError> {
328    if &command.agent_id != target_agent {
329        return Err(InputPrepareError::AgentTargetMismatch {
330            command_agent: command.agent_id,
331            target_agent: target_agent.clone(),
332        });
333    }
334    if command.name.is_empty()
335        || command.name.len() > 128
336        || !command.name.chars().all(|character| {
337            character.is_ascii_lowercase()
338                || character.is_ascii_digit()
339                || matches!(character, '-' | '_')
340        })
341    {
342        return Err(InputPrepareError::InvalidAgentCommandName(command.name));
343    }
344
345    let mut text = format!("/{}", command.name);
346    for (argument_index, argument) in command.arguments.iter().enumerate() {
347        if argument.is_empty()
348            || argument
349                .chars()
350                .any(|character| character.is_control() || character == '\u{1b}')
351        {
352            return Err(InputPrepareError::InvalidAgentCommandArgument { argument_index });
353        }
354        text.push(' ');
355        text.push_str(argument);
356    }
357
358    let chunks = bounded_chunks(
359        &text,
360        TERMINAL_INPUT_CHUNK_MAX_BYTES,
361        TERMINAL_INPUT_MAX_BYTES,
362    )?;
363    let mut writes: Vec<_> = chunks
364        .into_iter()
365        .map(|bytes| PreparedWrite {
366            kind: PreparedWriteKind::Data,
367            bytes,
368            delay_before_ms: 0,
369        })
370        .collect();
371    writes.push(PreparedWrite {
372        kind: PreparedWriteKind::Submit,
373        bytes: TerminalControl::Enter.bytes().to_vec(),
374        delay_before_ms: TERMINAL_SUBMIT_DELAY_MS,
375    });
376    Ok(PreparedInput {
377        kind: PreparedInputKind::AgentCommand,
378        writes,
379    })
380}
381
382/// Prepare an intentional command line for a dispatcher that has just
383/// confirmed a shell owns the PTY foreground.
384///
385/// Shell syntax is preserved. Control characters are rejected so one action
386/// cannot smuggle extra terminal writes or framing sequences past the typed
387/// protocol. Submission remains a separate delayed write.
388pub fn prepare_shell_command(command: ShellCommand) -> Result<PreparedInput, InputPrepareError> {
389    if command.text.trim().is_empty() {
390        return Err(InputPrepareError::EmptyShellCommand);
391    }
392    if let Some((index, character)) = command
393        .text
394        .char_indices()
395        .find(|(_, character)| character.is_control())
396    {
397        return Err(InputPrepareError::ControlCharacterInShellCommand {
398            index,
399            codepoint: character as u32,
400        });
401    }
402
403    let chunks = bounded_chunks(
404        &command.text,
405        TERMINAL_INPUT_CHUNK_MAX_BYTES,
406        TERMINAL_INPUT_MAX_BYTES,
407    )?;
408    let mut writes: Vec<_> = chunks
409        .into_iter()
410        .map(|bytes| PreparedWrite {
411            kind: PreparedWriteKind::Data,
412            bytes,
413            delay_before_ms: 0,
414        })
415        .collect();
416    writes.push(PreparedWrite {
417        kind: PreparedWriteKind::Submit,
418        bytes: TerminalControl::Enter.bytes().to_vec(),
419        delay_before_ms: TERMINAL_SUBMIT_DELAY_MS,
420    });
421    Ok(PreparedInput {
422        kind: PreparedInputKind::ShellCommand,
423        writes,
424    })
425}
426
427fn prepare_prompt(
428    payload: PromptPayload,
429    submit: bool,
430    max_chunk_bytes: usize,
431    max_total_bytes: usize,
432) -> Result<PreparedInput, InputPrepareError> {
433    if payload.text.len() > max_total_bytes {
434        return Err(InputPrepareError::InputTooLarge {
435            bytes: payload.text.len(),
436            max: max_total_bytes,
437        });
438    }
439    let sanitized = sanitize_prompt_text(&payload.text);
440    let chunks = bounded_chunks(&sanitized, max_chunk_bytes, max_total_bytes)?;
441    let mut writes = Vec::with_capacity(chunks.len() + 3);
442
443    if payload.framing == PromptFraming::BracketedPaste {
444        writes.push(PreparedWrite {
445            kind: PreparedWriteKind::Framing,
446            bytes: BRACKETED_PASTE_START.to_vec(),
447            delay_before_ms: 0,
448        });
449    }
450    writes.extend(chunks.into_iter().map(|bytes| PreparedWrite {
451        kind: PreparedWriteKind::Data,
452        bytes,
453        delay_before_ms: 0,
454    }));
455    if payload.framing == PromptFraming::BracketedPaste {
456        writes.push(PreparedWrite {
457            kind: PreparedWriteKind::Framing,
458            bytes: BRACKETED_PASTE_END.to_vec(),
459            delay_before_ms: 0,
460        });
461    }
462    if submit {
463        writes.push(PreparedWrite {
464            kind: PreparedWriteKind::Submit,
465            bytes: TerminalControl::Enter.bytes().to_vec(),
466            delay_before_ms: TERMINAL_SUBMIT_DELAY_MS,
467        });
468    }
469
470    Ok(PreparedInput {
471        kind: if submit {
472            PreparedInputKind::SubmitPrompt
473        } else {
474            PreparedInputKind::InsertDraft
475        },
476        writes,
477    })
478}
479
480/// Neutralizes terminal control sequences while preserving prompt newlines and tabs.
481pub fn sanitize_prompt_text(text: &str) -> String {
482    let mut sanitized = String::with_capacity(text.len());
483    for character in text.chars() {
484        match character {
485            '\n' | '\r' | '\t' => sanitized.push(character),
486            '\u{1b}' => sanitized.push_str("<ESC>"),
487            '\u{009b}' => sanitized.push_str("<CSI>"),
488            '\u{009d}' => sanitized.push_str("<OSC>"),
489            value if value.is_control() => {
490                use std::fmt::Write;
491                let _ = write!(sanitized, "<U+{:04X}>", value as u32);
492            }
493            value => sanitized.push(value),
494        }
495    }
496    sanitized
497}
498
499fn bounded_chunks(
500    text: &str,
501    max_chunk_bytes: usize,
502    max_total_bytes: usize,
503) -> Result<Vec<Vec<u8>>, InputPrepareError> {
504    if text.len() > max_total_bytes {
505        return Err(InputPrepareError::InputTooLarge {
506            bytes: text.len(),
507            max: max_total_bytes,
508        });
509    }
510    if text.is_empty() {
511        return Ok(Vec::new());
512    }
513
514    let mut chunks = Vec::new();
515    let mut start = 0;
516    let mut current_bytes = 0;
517    for (index, character) in text.char_indices() {
518        let character_bytes = character.len_utf8();
519        if character_bytes > max_chunk_bytes {
520            return Err(InputPrepareError::ChunkLimitTooSmall {
521                bytes: character_bytes,
522                max: max_chunk_bytes,
523            });
524        }
525        if current_bytes + character_bytes > max_chunk_bytes {
526            chunks.push(text.as_bytes()[start..index].to_vec());
527            start = index;
528            current_bytes = 0;
529        }
530        current_bytes += character_bytes;
531    }
532    chunks.push(text.as_bytes()[start..].to_vec());
533    Ok(chunks)
534}
535
536#[derive(Clone, Debug, Deserialize, Eq, Error, PartialEq, Serialize)]
537pub enum InputPrepareError {
538    #[error("terminal input chunk limit cannot be zero")]
539    ZeroChunkLimit,
540    #[error("input is {bytes} bytes; the configured limit is {max} bytes")]
541    InputTooLarge { bytes: usize, max: usize },
542    #[error("a {bytes}-byte UTF-8 code point does not fit the {max}-byte chunk limit")]
543    ChunkLimitTooSmall { bytes: usize, max: usize },
544    #[error("terminal byte sequence cannot be empty")]
545    EmptyTerminalBytes,
546    #[error(
547        "terminal text contains control U+{codepoint:04X} at byte {index}; use TerminalControl"
548    )]
549    ControlCharacterInTerminalText { index: usize, codepoint: u32 },
550    #[error("agent commands require a provider-specific capability dispatcher")]
551    AgentDispatcherRequired,
552    #[error("agent command targets '{command_agent}', but foreground dispatcher selected '{target_agent}'")]
553    AgentTargetMismatch {
554        command_agent: AgentId,
555        target_agent: AgentId,
556    },
557    #[error("invalid agent command name '{0}'")]
558    InvalidAgentCommandName(String),
559    #[error("agent command argument {argument_index} is empty or contains terminal controls")]
560    InvalidAgentCommandArgument { argument_index: usize },
561    #[error("shell commands require a confirmed foreground shell dispatcher")]
562    ShellDispatcherRequired,
563    #[error("shell command cannot be empty")]
564    EmptyShellCommand,
565    #[error("shell command contains control U+{codepoint:04X} at byte {index}")]
566    ControlCharacterInShellCommand { index: usize, codepoint: u32 },
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn payload(text: &str) -> PromptPayload {
574        PromptPayload {
575            text: text.to_owned(),
576            framing: PromptFraming::BracketedPaste,
577        }
578    }
579
580    #[test]
581    fn draft_and_submit_have_distinct_write_sequences() {
582        let draft = prepare_input(InputAction::InsertDraft(payload("hello"))).unwrap();
583        let submit = prepare_input(InputAction::SubmitPrompt(payload("hello"))).unwrap();
584        assert_eq!(draft.writes.last().unwrap().bytes, BRACKETED_PASTE_END);
585        assert_eq!(
586            submit.writes.last().unwrap().kind,
587            PreparedWriteKind::Submit
588        );
589        assert_eq!(submit.writes.last().unwrap().bytes, b"\r");
590        assert_eq!(
591            submit.writes.last().unwrap().delay_before_ms,
592            TERMINAL_SUBMIT_DELAY_MS
593        );
594    }
595
596    #[test]
597    fn prompt_controls_are_inert_inside_bracketed_paste() {
598        let prepared = prepare_input(InputAction::InsertDraft(payload("a\x1b[31m\0b"))).unwrap();
599        let joined: Vec<u8> = prepared
600            .writes
601            .iter()
602            .flat_map(|write| write.bytes.iter().copied())
603            .collect();
604        assert_eq!(
605            String::from_utf8(joined).unwrap(),
606            "\x1b[200~a<ESC>[31m<U+0000>b\x1b[201~"
607        );
608    }
609
610    #[test]
611    fn chunks_never_split_unicode() {
612        let prepared = prepare_input_with_limits(
613            InputAction::TerminalText(TerminalText {
614                text: "aЖ🙂b".to_owned(),
615            }),
616            4,
617            32,
618        )
619        .unwrap();
620        let decoded: Vec<_> = prepared
621            .writes
622            .iter()
623            .map(|write| std::str::from_utf8(&write.bytes).unwrap())
624            .collect();
625        assert_eq!(decoded, ["aЖ", "🙂", "b"]);
626    }
627
628    #[test]
629    fn raw_controls_require_the_control_variant() {
630        let error = prepare_input(InputAction::TerminalText(TerminalText {
631            text: "hello\n".to_owned(),
632        }))
633        .unwrap_err();
634        assert!(matches!(
635            error,
636            InputPrepareError::ControlCharacterInTerminalText { .. }
637        ));
638    }
639
640    #[test]
641    fn terminal_bytes_preserve_one_bounded_sequence_without_framing() {
642        let sequence = b"\x1b[1;5D".to_vec();
643        let prepared = prepare_input(InputAction::TerminalBytes(sequence.clone())).unwrap();
644        assert_eq!(prepared.kind(), PreparedInputKind::TerminalBytes);
645        assert_eq!(
646            prepared.writes(),
647            &[PreparedWrite {
648                kind: PreparedWriteKind::Control,
649                bytes: sequence,
650                delay_before_ms: 0,
651            }]
652        );
653    }
654
655    #[test]
656    fn terminal_bytes_reject_empty_and_oversized_sequences() {
657        assert_eq!(
658            prepare_input(InputAction::TerminalBytes(Vec::new())).unwrap_err(),
659            InputPrepareError::EmptyTerminalBytes,
660        );
661        assert_eq!(
662            prepare_input(InputAction::TerminalBytes(vec![0; TERMINAL_BYTES_MAX_BYTES + 1]))
663                .unwrap_err(),
664            InputPrepareError::InputTooLarge {
665                bytes: TERMINAL_BYTES_MAX_BYTES + 1,
666                max: TERMINAL_BYTES_MAX_BYTES,
667            },
668        );
669    }
670
671    #[test]
672    fn arrow_controls_preserve_terminal_escape_sequences() {
673        assert_eq!(TerminalControl::ArrowUp.bytes(), b"\x1b[A");
674        assert_eq!(TerminalControl::ArrowDown.bytes(), b"\x1b[B");
675        assert_eq!(TerminalControl::ArrowRight.bytes(), b"\x1b[C");
676        assert_eq!(TerminalControl::ArrowLeft.bytes(), b"\x1b[D");
677    }
678
679    #[test]
680    fn interactive_terminal_controls_preserve_xterm_sequences() {
681        assert_eq!(TerminalControl::BackTab.bytes(), b"\x1b[Z");
682        assert_eq!(TerminalControl::Insert.bytes(), b"\x1b[2~");
683        assert_eq!(TerminalControl::Delete.bytes(), b"\x1b[3~");
684        assert_eq!(TerminalControl::Home.bytes(), b"\x1b[H");
685        assert_eq!(TerminalControl::End.bytes(), b"\x1b[F");
686        assert_eq!(TerminalControl::PageUp.bytes(), b"\x1b[5~");
687        assert_eq!(TerminalControl::PageDown.bytes(), b"\x1b[6~");
688        assert_eq!(TerminalControl::Function1.bytes(), b"\x1bOP");
689        assert_eq!(TerminalControl::Function12.bytes(), b"\x1b[24~");
690        assert_eq!(TerminalControl::ControlA.bytes(), b"\x01");
691        assert_eq!(TerminalControl::ControlZ.bytes(), b"\x1a");
692    }
693
694    #[test]
695    fn agent_command_is_target_bound_and_delays_submit() {
696        let agent = AgentId::new("codex").unwrap();
697        let prepared = prepare_agent_command(
698            AgentCommand {
699                agent_id: agent.clone(),
700                name: "review".to_owned(),
701                arguments: vec!["focus on PTY ordering".to_owned()],
702            },
703            &agent,
704        )
705        .unwrap();
706        assert_eq!(prepared.writes[0].bytes, b"/review focus on PTY ordering");
707        assert_eq!(
708            prepared.writes.last().unwrap().delay_before_ms,
709            TERMINAL_SUBMIT_DELAY_MS
710        );
711    }
712
713    #[test]
714    fn agent_command_rejects_controls_and_foreign_targets() {
715        let codex = AgentId::new("codex").unwrap();
716        let kimi = AgentId::new("kimi").unwrap();
717        let foreign = prepare_agent_command(
718            AgentCommand {
719                agent_id: codex.clone(),
720                name: "help".to_owned(),
721                arguments: Vec::new(),
722            },
723            &kimi,
724        )
725        .unwrap_err();
726        assert!(matches!(
727            foreign,
728            InputPrepareError::AgentTargetMismatch { .. }
729        ));
730
731        let control = prepare_agent_command(
732            AgentCommand {
733                agent_id: codex.clone(),
734                name: "rename".to_owned(),
735                arguments: vec!["unsafe\rsubmit".to_owned()],
736            },
737            &codex,
738        )
739        .unwrap_err();
740        assert!(matches!(
741            control,
742            InputPrepareError::InvalidAgentCommandArgument { .. }
743        ));
744    }
745
746    #[test]
747    fn shell_command_preserves_syntax_and_submits_separately() {
748        let prepared = prepare_shell_command(ShellCommand {
749            text: "printf '%s' \"hello world\" | sed 's/world/shell/'".to_owned(),
750        })
751        .unwrap();
752
753        assert_eq!(prepared.kind(), PreparedInputKind::ShellCommand);
754        assert_eq!(
755            prepared.writes[0].bytes,
756            b"printf '%s' \"hello world\" | sed 's/world/shell/'"
757        );
758        assert_eq!(prepared.writes.last().unwrap().bytes, b"\r");
759        assert_eq!(
760            prepared.writes.last().unwrap().delay_before_ms,
761            TERMINAL_SUBMIT_DELAY_MS
762        );
763    }
764
765    #[test]
766    fn shell_command_rejects_empty_and_control_bearing_text() {
767        assert_eq!(
768            prepare_shell_command(ShellCommand {
769                text: "   ".to_owned(),
770            })
771            .unwrap_err(),
772            InputPrepareError::EmptyShellCommand
773        );
774        assert!(matches!(
775            prepare_shell_command(ShellCommand {
776                text: "echo first\necho second".to_owned(),
777            })
778            .unwrap_err(),
779            InputPrepareError::ControlCharacterInShellCommand {
780                index: 10,
781                codepoint: 10
782            }
783        ));
784    }
785}