1use std::sync::Arc;
16
17use forge_primitives::SessionSlot;
18use forge_primitives::browser::HandOffEnding;
19use forge_sdk::mcp::server::McpServerBuilder;
20use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
21use forge_terminal::Key;
22use serde_json::Value;
23
24use crate::mcp::terminal::facade::{OpenRequest, TerminalFacade, WaitUntil};
25
26pub mod facade;
27
28const DEFAULT_MAX_LINES: usize = 200;
30const DEFAULT_MAX_BYTES: usize = 32 * 1024;
31const DEFAULT_TIMEOUT_MS: u64 = 30_000;
33
34pub(crate) fn add_tools(
36 builder: McpServerBuilder,
37 facade: Arc<dyn TerminalFacade>,
38 slot: SessionSlot,
39) -> McpServerBuilder {
40 let mut builder = builder;
41 let mut tools = [
42 TerminalTool::Open,
43 TerminalTool::Write,
44 TerminalTool::Read,
45 TerminalTool::Wait,
46 TerminalTool::Resize,
47 TerminalTool::Close,
48 TerminalTool::HandOff,
49 TerminalTool::Run,
50 ]
51 .into_iter()
52 .peekable();
53 while let Some(tool) = tools.next() {
54 if tools.peek().is_none() {
57 return builder.tool(Registered { tool, facade, slot });
58 }
59 builder =
60 builder.tool(Registered { tool, facade: Arc::clone(&facade), slot: slot.clone() });
61 }
62 builder
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67enum TerminalTool {
68 Open,
69 Write,
70 Read,
71 Wait,
72 Resize,
73 Close,
74 HandOff,
75 Run,
76}
77
78impl TerminalTool {
79 const fn name(self) -> &'static str {
80 match self {
81 Self::Open => "terminal__open",
82 Self::Write => "terminal__write",
83 Self::Read => "terminal__read",
84 Self::Wait => "terminal__wait",
85 Self::Resize => "terminal__resize",
86 Self::Close => "terminal__close",
87 Self::HandOff => "terminal__hand_off",
88 Self::Run => "terminal__run",
89 }
90 }
91
92 const fn description(self) -> &'static str {
93 match self {
94 Self::Open => {
95 "Open this seat's terminal (one per seat, created on first use) and answer its \
96 screen. The terminal is a real PTY: the shell is the person's own, its cwd and \
97 environment are the seat's, and a password prompt, sudo, ssh and a curses \
98 program behave as they do in a normal terminal. **Prefer the CLI's own Bash \
99 tool for a non-interactive command**: it already returns the exit status and \
100 output without a terminal. Use this for the TTY cases - an interactive CLI, a \
101 curses program, a password prompt (hand it to the person with \
102 terminal__hand_off), or a command the person should be able to watch. `cwd` \
103 applies only when the terminal is first created; later opens return the same \
104 terminal. `command` is typed into the shell once it is ready (text, then \
105 Enter). `cols` and `rows` set the size when both are given."
106 }
107 Self::Write => {
108 "Type into this seat's terminal. `text` is typed as-is; `keys` is one or more \
109 named specials pressed in order: enter, tab, escape, up, down, left, right, \
110 home, end, page_up, page_down, backspace, ctrl_c, ctrl_d, ctrl_z, ctrl_l. \
111 Nothing is a command API: the bytes reach the shell as a person's would. \
112 If the person holds the keyboard, this call takes it from them (their client \
113 says so). Refused while a hand-off is parked, because the person owns the \
114 input then. Make sure the terminal has finished running a command before \
115 typing (see terminal__wait) - keystrokes typed while a program is busy go to \
116 that program."
117 }
118 Self::Read => {
119 "Read this seat's terminal as plain text, never raw escapes. The text is the \
120 scrollback and the visible screen, bounded by `max_lines` (default 200) and \
121 `max_bytes` (default 32 KiB); `truncated` says the bound cut it. `cursor` is \
122 the position to pass as `since` on the next read: it answers what appeared \
123 after it, and repeats the last line it gave you, because that line can still \
124 change while a program writes. Output is never pushed to you: reading is how \
125 you see it. Refused while a hand-off is parked."
126 }
127 Self::Wait => {
128 "Wait on this seat's terminal. `until` is \"exit\" (the running command \
129 finished; exact through the shell's OSC 133 marks, with the heuristic falling \
130 back to a quiet prompt and `state.source` saying which), \"quiet\" (no output \
131 for the quiet window), or an object {pattern: \"text\"} (the screen contains \
132 it). `timeout_ms` defaults to 30000; `matched` says whether the condition \
133 arrived before it. The answer carries the current screen text and cursor, so \
134 you usually do not need a separate read."
135 }
136 Self::Resize => {
137 "Resize this seat's terminal, in columns and rows. A client's attach resizes it \
138 too; use this when you need a wider screen for a tool. Refused outside 2..=500 \
139 columns and 2..=300 rows."
140 }
141 Self::Close => {
142 "Close this seat's terminal and kill its process group. The seat's next \
143 terminal__open starts a fresh shell in the seat's cwd. **A closed terminal's \
144 scrollback is gone**: read what you need first."
145 }
146 Self::HandOff => {
147 "Hand this seat's terminal to the person at a client for the one thing only they \
148 can do: a password, a sudo confirmation, a Y/N prompt, a device sign-in. The \
149 ask draws a card on the seat's strip and parks, with no timeout, until they \
150 answer; if the surface is not up, the card's Open terminal button raises it. \
151 **You are blind while it is parked**: terminal__read and terminal__write are \
152 refused. The ask takes the keyboard from the person if they held it, and the \
153 raised surface is a viewer: they take control there to type, and the ending \
154 hands the keyboard back to you. Their keystrokes go to the PTY rather than to \
155 you, but the shell may echo them, so a read after the hand-off can carry what \
156 was typed. `reason` is \
157 what they should do. The answer is the ending only - done means carry on; \
158 not_now means decide what to do instead; feedback means they sent you a message \
159 about it. Verify the outcome by reading the artifact or the terminal, never by \
160 assuming. `secret: true` on the card means the program is reading with ECHO \
161 off, so their typing is hidden."
162 }
163 Self::Run => {
164 "Run one command in a fresh terminal and answer its exit and text. This is \
165 terminal__open + terminal__wait {until: \"exit\"} + terminal__close with a \
166 guaranteed close on every path, so it is the right call when you just need a \
167 TTY-backed command's result. `command` is typed into the shell once it is \
168 ready. `cwd` defaults to the seat's own. `timeout_ms` defaults to 30000; a \
169 timeout kills the process group and the answer carries signal 9. The text is \
170 bounded and `truncated` says so."
171 }
172 }
173 }
174
175 fn schema(self) -> Value {
176 match self {
177 Self::Open => serde_json::json!({
178 "type": "object",
179 "properties": {
180 "command": {
181 "type": "string",
182 "description": "A command typed into the shell once it is ready.",
183 },
184 "cwd": {
185 "type": "string",
186 "description": "The cwd the terminal is first spawned in; the seat's own \
187 when omitted.",
188 },
189 "cols": {"type": "integer", "minimum": 2, "maximum": 500},
190 "rows": {"type": "integer", "minimum": 2, "maximum": 300},
191 },
192 }),
193 Self::Write => serde_json::json!({
194 "type": "object",
195 "properties": {
196 "id": {"type": "integer", "description": "The terminal id terminal__open returned."},
197 "text": {"type": "string", "description": "Text typed as-is."},
198 "keys": {
199 "type": "array",
200 "items": {
201 "type": "string",
202 "enum": [
203 "enter", "tab", "escape", "up", "down", "left", "right",
204 "home", "end", "page_up", "page_down", "backspace",
205 "ctrl_c", "ctrl_d", "ctrl_z", "ctrl_l",
206 ],
207 },
208 "description": "Named keys pressed in order.",
209 },
210 },
211 "required": ["id"],
212 }),
213 Self::Read => serde_json::json!({
214 "type": "object",
215 "properties": {
216 "id": {"type": "integer", "description": "The terminal id terminal__open returned."},
217 "since": {
218 "type": "integer",
219 "description": "The cursor a previous read returned; only lines after it \
220 are read.",
221 },
222 "max_lines": {"type": "integer", "minimum": 1, "default": DEFAULT_MAX_LINES},
223 "max_bytes": {"type": "integer", "minimum": 1, "default": DEFAULT_MAX_BYTES},
224 },
225 "required": ["id"],
226 }),
227 Self::Wait => serde_json::json!({
228 "type": "object",
229 "properties": {
230 "id": {"type": "integer", "description": "The terminal id terminal__open returned."},
231 "until": {
232 "description": "What to wait for: \"exit\", \"quiet\", or {pattern: \"text\"}.",
233 "anyOf": [
234 {"type": "string", "enum": ["exit", "quiet"]},
235 {
236 "type": "object",
237 "properties": {"pattern": {"type": "string"}},
238 "required": ["pattern"],
239 },
240 ],
241 },
242 "timeout_ms": {"type": "integer", "minimum": 1, "default": DEFAULT_TIMEOUT_MS},
243 },
244 "required": ["id", "until"],
245 }),
246 Self::Resize => serde_json::json!({
247 "type": "object",
248 "properties": {
249 "id": {"type": "integer", "description": "The terminal id terminal__open returned."},
250 "cols": {"type": "integer", "minimum": 2, "maximum": 500},
251 "rows": {"type": "integer", "minimum": 2, "maximum": 300},
252 },
253 "required": ["id", "cols", "rows"],
254 }),
255 Self::Close => serde_json::json!({
256 "type": "object",
257 "properties": {
258 "id": {"type": "integer", "description": "The terminal id terminal__open returned."},
259 },
260 "required": ["id"],
261 }),
262 Self::HandOff => serde_json::json!({
263 "type": "object",
264 "properties": {
265 "id": {"type": "integer", "description": "The terminal id terminal__open returned."},
266 "reason": {
267 "type": "string",
268 "description": "What the person should do, in your own words.",
269 },
270 },
271 "required": ["id", "reason"],
272 }),
273 Self::Run => serde_json::json!({
274 "type": "object",
275 "properties": {
276 "command": {"type": "string", "description": "The command to run."},
277 "cwd": {
278 "type": "string",
279 "description": "The cwd to run in; the seat's own when omitted.",
280 },
281 "timeout_ms": {"type": "integer", "minimum": 1, "default": DEFAULT_TIMEOUT_MS},
282 },
283 "required": ["command"],
284 }),
285 }
286 }
287}
288
289struct Registered {
291 tool: TerminalTool,
292 facade: Arc<dyn TerminalFacade>,
293 slot: SessionSlot,
294}
295
296#[async_trait::async_trait]
297impl Tool for Registered {
298 fn name(&self) -> &'static str {
299 self.tool.name()
300 }
301
302 fn description(&self) -> &'static str {
303 self.tool.description()
304 }
305
306 fn input_schema(&self) -> Value {
307 self.tool.schema()
308 }
309
310 async fn call(&self, input: ToolInput) -> ToolOutput {
311 let started = std::time::Instant::now();
312 let outcome = self.dispatch(input.value).await;
313 tracing::debug!(
314 target: "forge_workspace::terminal",
315 event_name = "terminal_call",
316 tool = self.tool.name(),
317 slot = %self.slot.display(),
318 ok = outcome.is_ok(),
319 ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
320 "a terminal tool call returned",
321 );
322 match outcome {
323 Ok(text) => {
324 ToolOutput { blocks: vec![ToolOutputBlock::Text { text }], is_error: false }
325 }
326 Err(why) => ToolOutput::error(why),
327 }
328 }
329}
330
331impl Registered {
332 async fn dispatch(&self, args: Value) -> Result<String, String> {
333 match self.tool {
334 TerminalTool::Open => self.open(args).await,
335 TerminalTool::Write => self.write(args).await,
336 TerminalTool::Read => self.read(args).await,
337 TerminalTool::Wait => self.wait(args).await,
338 TerminalTool::Resize => self.resize(args).await,
339 TerminalTool::Close => self.close(args).await,
340 TerminalTool::HandOff => self.hand_off(args).await,
341 TerminalTool::Run => self.run(args).await,
342 }
343 }
344
345 async fn open(&self, args: Value) -> Result<String, String> {
346 let request = OpenRequest {
347 command: optional_string(&args, "command")?,
348 cwd: optional_string(&args, "cwd")?,
349 cols: optional_u16(&args, "cols")?,
350 rows: optional_u16(&args, "rows")?,
351 };
352 let answer = self.facade.open(&self.slot, request).await?;
353 Ok(serde_json::json!({
354 "id": answer.id,
355 "screen": answer.screen,
356 "cols": answer.cols,
357 "rows": answer.rows,
358 "shell": answer.shell,
359 "cwd": answer.cwd,
360 })
361 .to_string())
362 }
363
364 async fn write(&self, args: Value) -> Result<String, String> {
365 let id = required_u16(&args, "id")?;
366 let text = optional_string(&args, "text")?;
367 let keys = parse_keys(&args)?;
368 if text.as_deref().is_none_or(str::is_empty) && keys.is_empty() {
369 return Err("terminal__write needs `text`, `keys`, or both".to_owned());
370 }
371 let state = self.facade.write(&self.slot, id, text, keys).await?;
372 Ok(serde_json::json!({ "ok": true, "state": state }).to_string())
373 }
374
375 async fn read(&self, args: Value) -> Result<String, String> {
376 let id = required_u16(&args, "id")?;
377 let since = match args.get("since") {
378 None | Some(Value::Null) => None,
379 Some(value) => Some(value.as_u64().ok_or_else(|| {
380 "terminal__read `since` is the cursor a previous read returned, as an integer"
381 .to_owned()
382 })?),
383 };
384 let max_lines = optional_usize(&args, "max_lines")?.unwrap_or(DEFAULT_MAX_LINES);
385 let max_bytes = optional_usize(&args, "max_bytes")?.unwrap_or(DEFAULT_MAX_BYTES);
386 let answer = self.facade.read(&self.slot, id, since, max_lines, max_bytes).await?;
387 Ok(serde_json::json!({
388 "text": answer.text,
389 "cursor": answer.cursor,
390 "cols": answer.cols,
391 "rows": answer.rows,
392 "state": answer.state,
393 "truncated": answer.truncated,
394 })
395 .to_string())
396 }
397
398 async fn wait(&self, args: Value) -> Result<String, String> {
399 let id = required_u16(&args, "id")?;
400 let until = parse_until(&args)?;
401 let timeout_ms = optional_u64(&args, "timeout_ms")?.unwrap_or(DEFAULT_TIMEOUT_MS);
402 let answer = self.facade.wait(&self.slot, id, until, timeout_ms).await?;
403 Ok(serde_json::json!({
404 "matched": answer.matched,
405 "text": answer.text,
406 "cursor": answer.cursor,
407 "state": answer.state,
408 "exited": answer.state.exit,
409 })
410 .to_string())
411 }
412
413 async fn resize(&self, args: Value) -> Result<String, String> {
414 let id = required_u16(&args, "id")?;
415 let cols = required_u16(&args, "cols")?;
416 let rows = required_u16(&args, "rows")?;
417 if !(forge_primitives::terminal::MIN_COLS..=forge_primitives::terminal::MAX_COLS)
418 .contains(&cols)
419 || !(forge_primitives::terminal::MIN_ROWS..=forge_primitives::terminal::MAX_ROWS)
420 .contains(&rows)
421 {
422 return Err(format!(
423 "terminal__resize takes {min_cols}..={max_cols} columns and {min_rows}..={max_rows} \
424 rows",
425 min_cols = forge_primitives::terminal::MIN_COLS,
426 max_cols = forge_primitives::terminal::MAX_COLS,
427 min_rows = forge_primitives::terminal::MIN_ROWS,
428 max_rows = forge_primitives::terminal::MAX_ROWS,
429 ));
430 }
431 self.facade.resize(&self.slot, id, cols, rows).await?;
432 Ok(serde_json::json!({ "ok": true }).to_string())
433 }
434
435 async fn close(&self, args: Value) -> Result<String, String> {
436 let id = required_u16(&args, "id")?;
437 let exit = self.facade.close(&self.slot, id).await?;
438 Ok(serde_json::json!({ "ok": true, "exit": exit }).to_string())
439 }
440
441 async fn hand_off(&self, args: Value) -> Result<String, String> {
442 let id = required_u16(&args, "id")?;
443 let Some(reason) =
444 optional_string(&args, "reason")?.filter(|reason| !reason.trim().is_empty())
445 else {
446 return Err("terminal__hand_off needs `reason`: what the person should do".to_owned());
447 };
448 match self.facade.hand_off(&self.slot, id, &reason).await? {
449 HandOffEnding::Done => Ok(
450 "The person is done in the terminal; carry on from where you left off. Read the \
451 terminal or the artifact to verify the outcome."
452 .to_string(),
453 ),
454 HandOffEnding::NotNow => Err(
455 "The person declined to act for now; the terminal is as it was - decide what to \
456 do instead."
457 .to_string(),
458 ),
459 HandOffEnding::Feedback => {
460 Ok("The person sent you a message about the terminal instead of Done or Not now; \
461 read it and carry on - the terminal is as it was."
462 .to_string())
463 }
464 HandOffEnding::Abandoned => Err("The hand-off ended without an answer.".to_string()),
465 }
466 }
467
468 async fn run(&self, args: Value) -> Result<String, String> {
469 let Some(command) =
470 optional_string(&args, "command")?.filter(|command| !command.trim().is_empty())
471 else {
472 return Err("terminal__run needs `command`: the command to run".to_owned());
473 };
474 let cwd = optional_string(&args, "cwd")?;
475 let timeout_ms = optional_u64(&args, "timeout_ms")?.unwrap_or(DEFAULT_TIMEOUT_MS);
476 let answer = self.facade.run(&self.slot, &command, cwd, timeout_ms).await?;
477 Ok(serde_json::json!({
478 "exit": answer.exit,
479 "text": answer.text,
480 "truncated": answer.truncated,
481 })
482 .to_string())
483 }
484}
485
486fn required_u16(args: &Value, key: &str) -> Result<u16, String> {
487 let value = args.get(key).ok_or_else(|| {
488 format!("this call needs `{key}`, the terminal id terminal__open returned")
489 })?;
490 u16::try_from(value.as_u64().ok_or_else(|| format!("`{key}` must be an integer"))?)
491 .map_err(|_| format!("`{key}` is out of range"))
492}
493
494fn optional_u16(args: &Value, key: &str) -> Result<Option<u16>, String> {
495 match args.get(key) {
496 None | Some(Value::Null) => Ok(None),
497 Some(value) => Ok(Some(
498 u16::try_from(value.as_u64().ok_or_else(|| format!("`{key}` must be an integer"))?)
499 .map_err(|_| format!("`{key}` is out of range"))?,
500 )),
501 }
502}
503
504fn optional_u64(args: &Value, key: &str) -> Result<Option<u64>, String> {
505 match args.get(key) {
506 None | Some(Value::Null) => Ok(None),
507 Some(value) => {
508 Ok(Some(value.as_u64().ok_or_else(|| format!("`{key}` must be an integer"))?))
509 }
510 }
511}
512
513fn optional_usize(args: &Value, key: &str) -> Result<Option<usize>, String> {
514 Ok(optional_u64(args, key)?.map(|value| usize::try_from(value).unwrap_or(usize::MAX)))
515}
516
517fn optional_string(args: &Value, key: &str) -> Result<Option<String>, String> {
518 match args.get(key) {
519 None | Some(Value::Null) => Ok(None),
520 Some(Value::String(value)) => Ok(Some(value.clone())),
521 Some(_) => Err(format!("`{key}` must be a string")),
522 }
523}
524
525fn parse_keys(args: &Value) -> Result<Vec<Key>, String> {
526 let Some(keys) = args.get("keys") else { return Ok(Vec::new()) };
527 if keys.is_null() {
528 return Ok(Vec::new());
529 }
530 let Some(keys) = keys.as_array() else {
531 return Err("terminal__write `keys` is an array of key names".to_owned());
532 };
533 let mut parsed = Vec::with_capacity(keys.len());
534 for key in keys {
535 let Some(name) = key.as_str() else {
536 return Err("terminal__write `keys` is an array of key names".to_owned());
537 };
538 let Some(key) = Key::parse(name) else {
539 return Err(format!(
540 "'{name}' is not a key this tool takes: enter, tab, escape, up, down, left, \
541 right, home, end, page_up, page_down, backspace, ctrl_c, ctrl_d, ctrl_z, ctrl_l"
542 ));
543 };
544 parsed.push(key);
545 }
546 Ok(parsed)
547}
548
549fn parse_until(args: &Value) -> Result<WaitUntil, String> {
550 let Some(until) = args.get("until") else {
551 return Err(
552 "terminal__wait needs `until`: \"exit\", \"quiet\", or {pattern: \"text\"}".to_owned()
553 );
554 };
555 match until {
556 Value::String(word) if word == "exit" => Ok(WaitUntil::Exit),
557 Value::String(word) if word == "quiet" => Ok(WaitUntil::Quiet),
558 Value::String(word) => Err(format!(
559 "'{word}' is not an `until` this tool takes: \"exit\", \"quiet\", or {{pattern: \
560 \"text\"}}"
561 )),
562 Value::Object(map) => {
563 let Some(pattern) = map.get("pattern").and_then(Value::as_str) else {
564 return Err("terminal__wait {pattern: ...} needs a non-empty string".to_owned());
565 };
566 if pattern.is_empty() {
567 return Err("terminal__wait {pattern: ...} needs a non-empty string".to_owned());
568 }
569 Ok(WaitUntil::Pattern(pattern.to_owned()))
570 }
571 _ => {
572 Err("terminal__wait needs `until`: \"exit\", \"quiet\", or {pattern: \"text\"}"
573 .to_owned())
574 }
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use crate::mcp::terminal::facade::MockTerminalFacade;
582 use serde_json::json;
583
584 fn seat() -> SessionSlot {
585 SessionSlot::from_str_for_test("caller")
586 }
587
588 fn tool(name: &str, facade: Arc<dyn TerminalFacade>) -> Registered {
589 let tool = [
590 TerminalTool::Open,
591 TerminalTool::Write,
592 TerminalTool::Read,
593 TerminalTool::Wait,
594 TerminalTool::Resize,
595 TerminalTool::Close,
596 TerminalTool::HandOff,
597 TerminalTool::Run,
598 ]
599 .into_iter()
600 .find(|tool| tool.name() == name)
601 .unwrap_or_else(|| panic!("{name} is not on the surface"));
602 Registered { tool, facade, slot: seat() }
603 }
604
605 fn text(out: &ToolOutput) -> String {
606 crate::mcp::test_support::block_text(&out.blocks[0]).to_owned()
607 }
608
609 #[test]
612 fn the_family_is_the_eight_tools() {
613 let names: Vec<&str> = [
614 TerminalTool::Open,
615 TerminalTool::Write,
616 TerminalTool::Read,
617 TerminalTool::Wait,
618 TerminalTool::Resize,
619 TerminalTool::Close,
620 TerminalTool::HandOff,
621 TerminalTool::Run,
622 ]
623 .iter()
624 .map(|tool| tool.name())
625 .collect();
626 assert_eq!(
627 names,
628 [
629 "terminal__open",
630 "terminal__write",
631 "terminal__read",
632 "terminal__wait",
633 "terminal__resize",
634 "terminal__close",
635 "terminal__hand_off",
636 "terminal__run",
637 ],
638 );
639 for name in &names {
640 assert!(name.starts_with("terminal__"), "{name} is under the terminal family");
641 }
642 }
643
644 #[tokio::test]
647 async fn a_write_carries_the_text_and_the_keys() {
648 let mock = Arc::new(MockTerminalFacade::new());
649 let out = tool("terminal__write", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
650 .call(ToolInput { value: json!({ "id": 1, "text": "ls", "keys": ["enter"] }) })
651 .await;
652 assert!(!out.is_error, "{:?}", out.blocks);
653 assert_eq!(
654 mock.writes.lock().as_slice(),
655 [(1, Some("ls".to_owned()), vec![Key::Enter])],
656 "the text and the key cross to the terminal",
657 );
658 }
659
660 #[tokio::test]
663 async fn a_write_with_nothing_to_type_is_refused() {
664 let mock = Arc::new(MockTerminalFacade::new());
665 let out = tool("terminal__write", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
666 .call(ToolInput { value: json!({ "id": 1 }) })
667 .await;
668 assert!(out.is_error);
669 assert!(text(&out).contains("`text`"), "{}", text(&out));
670 assert!(mock.writes.lock().is_empty());
671 }
672
673 #[tokio::test]
676 async fn an_unknown_key_is_refused_by_name() {
677 let mock = Arc::new(MockTerminalFacade::new());
678 let out = tool("terminal__write", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
679 .call(ToolInput { value: json!({ "id": 1, "keys": ["ctrl_q"] }) })
680 .await;
681 assert!(out.is_error);
682 assert!(text(&out).contains("ctrl_q"), "{}", text(&out));
683 assert!(text(&out).contains("ctrl_c"), "the set is named: {}", text(&out));
684 }
685
686 #[tokio::test]
689 async fn a_hand_off_parks_and_a_done_answers_it() {
690 let mock = Arc::new(MockTerminalFacade::new());
691 let out = tool("terminal__hand_off", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
692 .call(ToolInput { value: json!({ "id": 2, "reason": "sudo password" }) })
693 .await;
694 assert!(!out.is_error, "{:?}", out.blocks);
695 assert_eq!(
696 mock.hand_offs.lock().as_slice(),
697 [(2, "sudo password".to_owned())],
698 "the reason and the terminal cross to the park",
699 );
700 assert!(text(&out).contains("carry on"), "{}", text(&out));
701 }
702
703 #[tokio::test]
705 async fn a_hand_off_decline_is_a_tool_error() {
706 let mock = Arc::new(MockTerminalFacade::new());
707 *mock.hand_off_answer.lock() = Ok(HandOffEnding::NotNow);
708 let out = tool("terminal__hand_off", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
709 .call(ToolInput { value: json!({ "id": 2, "reason": "sign in" }) })
710 .await;
711 assert!(out.is_error);
712 assert!(text(&out).contains("declined"), "{}", text(&out));
713 }
714
715 #[tokio::test]
717 async fn a_hand_off_without_a_reason_is_refused() {
718 let mock = Arc::new(MockTerminalFacade::new());
719 let out = tool("terminal__hand_off", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
720 .call(ToolInput { value: json!({ "id": 2, "reason": " " }) })
721 .await;
722 assert!(out.is_error);
723 assert!(text(&out).contains("`reason`"), "{}", text(&out));
724 assert!(mock.hand_offs.lock().is_empty());
725 }
726
727 #[tokio::test]
730 async fn a_wait_parses_all_three_until_shapes() {
731 let mock = Arc::new(MockTerminalFacade::new());
732 let wait = |value: Value| {
733 let mock = Arc::clone(&mock);
734 async move {
735 tool("terminal__wait", mock as Arc<dyn TerminalFacade>)
736 .call(ToolInput { value })
737 .await
738 }
739 };
740 let _ = wait(json!({ "id": 1, "until": "exit" })).await;
741 let _ = wait(json!({ "id": 1, "until": "quiet" })).await;
742 let _ = wait(json!({ "id": 1, "until": { "pattern": "done" } })).await;
743 let seen = mock.waits.lock().clone();
744 assert_eq!(
745 seen,
746 vec![
747 (1, WaitUntil::Exit, DEFAULT_TIMEOUT_MS),
748 (1, WaitUntil::Quiet, DEFAULT_TIMEOUT_MS),
749 (1, WaitUntil::Pattern("done".to_owned()), DEFAULT_TIMEOUT_MS),
750 ],
751 );
752 }
753
754 #[tokio::test]
756 async fn a_resize_outside_the_bounds_is_refused() {
757 let mock = Arc::new(MockTerminalFacade::new());
758 for (cols, rows) in [(1, 24), (501, 24), (80, 1), (80, 301)] {
759 let out = tool("terminal__resize", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
760 .call(ToolInput { value: json!({ "id": 1, "cols": cols, "rows": rows }) })
761 .await;
762 assert!(out.is_error, "{cols}x{rows}: {:?}", out.blocks);
763 }
764 assert!(mock.resizes.lock().is_empty());
765 }
766
767 #[tokio::test]
769 async fn a_run_carries_the_command_and_answers_the_exit() {
770 let mock = Arc::new(MockTerminalFacade::new());
771 let out = tool("terminal__run", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
772 .call(ToolInput {
773 value: json!({ "command": "echo hi", "cwd": "/tmp", "timeout_ms": 1000 }),
774 })
775 .await;
776 assert!(!out.is_error, "{:?}", out.blocks);
777 assert_eq!(
778 mock.runs.lock().as_slice(),
779 [("echo hi".to_owned(), Some("/tmp".to_owned()), 1000)],
780 );
781 let answer: Value = serde_json::from_str(&text(&out)).expect("a JSON answer");
782 assert_eq!(answer["exit"]["code"], 0);
783 assert_eq!(answer["text"], "ran");
784 }
785
786 #[tokio::test]
788 async fn a_run_without_a_command_is_refused() {
789 let mock = Arc::new(MockTerminalFacade::new());
790 let out = tool("terminal__run", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
791 .call(ToolInput { value: json!({}) })
792 .await;
793 assert!(out.is_error);
794 assert!(text(&out).contains("`command`"), "{}", text(&out));
795 }
796
797 #[tokio::test]
800 async fn a_read_carries_the_bound_and_the_cursor() {
801 let mock = Arc::new(MockTerminalFacade::new());
802 let out = tool("terminal__read", Arc::clone(&mock) as Arc<dyn TerminalFacade>)
803 .call(ToolInput {
804 value: json!({ "id": 1, "since": 4, "max_lines": 10, "max_bytes": 100 }),
805 })
806 .await;
807 assert!(!out.is_error, "{:?}", out.blocks);
808 assert_eq!(mock.reads.lock().as_slice(), [(1, Some(4), 10, 100)]);
809 let answer: Value = serde_json::from_str(&text(&out)).expect("a JSON answer");
810 assert_eq!(answer["cursor"], 1);
811 assert_eq!(answer["truncated"], false);
812 }
813}