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 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 pub fn kind(&self) -> PreparedInputKind {
209 self.kind
210 }
211
212 pub fn writes(&self) -> &[PreparedWrite] {
214 &self.writes
215 }
216
217 pub fn into_writes(self) -> Vec<PreparedWrite> {
218 self.writes
219 }
220}
221
222pub 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
234pub 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
322pub 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
382pub 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
480pub 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}