Skip to main content

forge_workspace/mcp/
terminal.rs

1//! The terminal family: the seat's own terminal, driven by bytes and keys.
2//!
3//! **The agent drives it like a human.** There is no agent mode, no
4//! automation flag and no wrapper around the shell: text is typed as bytes,
5//! Enter is its own key, Ctrl-C is 0x03, and the shell sees an ordinary
6//! `xterm-256color` terminal. Nothing the terminal adds says an agent is
7//! behind the keys.
8//!
9//! **The family is any-caller and it is always on.** The terminal is one per
10//! seat on the machine, not a scope like reviews or crons, so every session
11//! may drive its own. No approval gate: Bash through the CLI already reaches
12//! the shell here. The one thing gated is the person's input, through a
13//! hand-off or their own take-control of the raised surface.
14
15use 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
28/// The most lines and bytes one read returns unless the caller asks for less.
29const DEFAULT_MAX_LINES: usize = 200;
30const DEFAULT_MAX_BYTES: usize = 32 * 1024;
31/// The longest a wait or a run may hold before it answers unmatched.
32const DEFAULT_TIMEOUT_MS: u64 = 30_000;
33
34/// Attach the terminal tools to an existing builder.
35pub(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        // The last tool takes the caller's own handles, so nothing is left
55        // holding the originals once all eight are registered.
56        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/// One tool of the family.
66#[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
289/// One registered tool, plus the caller it acts for.
290struct 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    /// **The family is the eight terminal tools.** A missing one is a prompt
610    /// that does not resolve; an extra one is a surface no test drives.
611    #[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    /// A write carries the text and the named keys, parsed to their bytes,
645    /// and answers the state.
646    #[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    /// A call with neither text nor keys is refused rather than answered with
661    /// nothing done.
662    #[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    /// An unknown key name is refused naming the key and the set, so a model
674    /// cannot quietly lose one key of a sequence.
675    #[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    /// A hand-off parks on the person and a done answers it with the words a
687    /// model can act on.
688    #[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    /// A decline is a tool error, so the model reads it as "not this way".
704    #[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    /// A blank reason is no ask: it would park a prompt that says nothing.
716    #[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    /// The wait parses all three `until` shapes and passes the default
728    /// timeout.
729    #[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    /// A resize outside the sane bounds is refused before it reaches the PTY.
755    #[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    /// A run carries the command and the cwd, and answers the exit and text.
768    #[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    /// A run without a command is the call's own mistake.
787    #[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    /// The read's bound and cursor cross to the facade and back, so a model
798    /// paging through output gets the shape the tool advertises.
799    #[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}