Skip to main content

sac/tools/
terminal.rs

1use std::path::PathBuf;
2use std::time::{Duration, Instant};
3
4use anyhow::{anyhow, Result};
5use serde_json::{json, Value};
6
7use crate::terminal::TerminalManager;
8use crate::tools::{require_str, ToolResult, ToolRuntime};
9use crate::types::{FunctionDef, ToolDefinition};
10
11pub fn terminal_definition() -> ToolDefinition {
12    ToolDefinition {
13        def_type: "function".to_string(),
14        function: FunctionDef {
15            name: "terminal".to_string(),
16            description: "Manage named persistent terminal sessions. Supports create, send, read, resize, wait, close, and list operations without replacing exec_command/write_stdin.".to_string(),
17            parameters: json!({
18                "type": "object",
19                "properties": {
20                    "operation": {
21                        "type": "string",
22                        "enum": ["create", "send", "read", "resize", "wait", "close", "list", "reset_command_state", "touch", "cleanup_ephemeral"],
23                        "description": "Terminal operation to perform"
24                    },
25                    "name": {
26                        "type": "string",
27                        "description": "Named terminal session to operate on"
28                    },
29                    "cwd": {
30                        "type": "string",
31                        "description": "Working directory for create"
32                    },
33                    "cols": {
34                        "type": "integer",
35                        "description": "Terminal width for create/resize (default 120)"
36                    },
37                    "rows": {
38                        "type": "integer",
39                        "description": "Terminal height for create/resize (default 40)"
40                    },
41                    "input": {
42                        "type": "string",
43                        "description": "Input text for send; supports upstream key notation like <RET>, <C-c>, <UP>"
44                    },
45                    "yield_time_ms": {
46                        "type": "integer",
47                        "description": "Polling/output wait duration in milliseconds for send and waits (default 500)"
48                    },
49                    "max_output_chars": {
50                        "type": "integer",
51                        "description": "Maximum returned output characters (default 8000)"
52                    },
53                    "lines": {
54                        "type": "integer",
55                        "description": "For read: number of lines from the retained history tail"
56                    },
57                    "wait_type": {
58                        "type": "string",
59                        "enum": ["output_contains", "idle"],
60                        "description": "Wait condition type"
61                    },
62                    "text": {
63                        "type": "string",
64                        "description": "Substring to wait for when wait_type=output_contains"
65                    },
66                    "idle_ms": {
67                        "type": "integer",
68                        "description": "Required quiet period in milliseconds when wait_type=idle (default 1000)"
69                    },
70                    "timeout_ms": {
71                        "type": "integer",
72                        "description": "Maximum wait duration in milliseconds (default 30000)"
73                    },
74                    "min_idle_ms": {
75                        "type": "integer",
76                        "description": "For cleanup_ephemeral: minimum idle time in milliseconds before removing exited ephemeral sessions"
77                    }
78                },
79                "required": ["operation"]
80            }),
81        },
82    }
83}
84
85pub async fn execute_terminal(args: &Value, runtime: &ToolRuntime) -> Result<String> {
86    let operation = require_str(args, "operation").map_err(tool_error_to_anyhow)?;
87    let manager = &runtime.terminal_manager;
88
89    match operation.as_str() {
90        "create" => execute_create(args, manager, runtime).await,
91        "send" => execute_send(args, manager).await,
92        "read" => execute_read(args, manager).await,
93        "resize" => execute_resize(args, manager).await,
94        "wait" => execute_wait(args, manager).await,
95        "close" => execute_close(args, manager).await,
96        "list" => execute_list(manager).await,
97        "reset_command_state" => execute_reset_command_state(args, manager).await,
98        "touch" => execute_touch(args, manager).await,
99        "cleanup_ephemeral" => execute_cleanup_ephemeral(args, manager).await,
100        other => Err(anyhow!("unknown terminal operation '{}'", other)),
101    }
102}
103
104async fn execute_create(
105    args: &Value,
106    manager: &TerminalManager,
107    runtime: &ToolRuntime,
108) -> Result<String> {
109    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
110    let cwd = args
111        .get("cwd")
112        .and_then(|value| value.as_str())
113        .map(PathBuf::from);
114    let cols = args
115        .get("cols")
116        .and_then(|value| value.as_u64())
117        .unwrap_or(120) as u16;
118    let rows = args
119        .get("rows")
120        .and_then(|value| value.as_u64())
121        .unwrap_or(40) as u16;
122
123    let info = manager
124        .create_named(name.clone(), cwd, cols, rows, runtime.sandbox.as_ref())
125        .await?;
126    Ok(serde_json::to_string_pretty(&json!({
127        "operation": "create",
128        "terminal": info,
129    }))?)
130}
131
132async fn execute_send(args: &Value, manager: &TerminalManager) -> Result<String> {
133    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
134    let input = args
135        .get("input")
136        .and_then(|value| value.as_str())
137        .unwrap_or("");
138    let yield_ms = args
139        .get("yield_time_ms")
140        .and_then(|value| value.as_u64())
141        .unwrap_or(500);
142    let max_output = args
143        .get("max_output_chars")
144        .and_then(|value| value.as_u64())
145        .unwrap_or(8000) as usize;
146
147    let output = manager
148        .write_stdin(&name, input, yield_ms, max_output)
149        .await?;
150    Ok(serde_json::to_string_pretty(&json!({
151        "operation": "send",
152        "result": output,
153    }))?)
154}
155
156async fn execute_read(args: &Value, manager: &TerminalManager) -> Result<String> {
157    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
158    let history = manager.read_history(&name).await?;
159    let lines = args
160        .get("lines")
161        .and_then(|value| value.as_u64())
162        .map(|value| value as usize);
163    let text = if let Some(lines) = lines {
164        tail_lines(&history, lines)
165    } else {
166        history
167    };
168    Ok(serde_json::to_string_pretty(&json!({
169        "operation": "read",
170        "name": name,
171        "output": text,
172    }))?)
173}
174
175async fn execute_resize(args: &Value, manager: &TerminalManager) -> Result<String> {
176    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
177    let cols = require_u16(args, "cols")?;
178    let rows = require_u16(args, "rows")?;
179    manager.resize(&name, cols, rows).await?;
180    let info = manager
181        .get(&name)
182        .await
183        .ok_or_else(|| anyhow!("terminal session '{}' vanished after resize", name))?;
184    Ok(serde_json::to_string_pretty(&json!({
185        "operation": "resize",
186        "terminal": info,
187    }))?)
188}
189
190async fn execute_wait(args: &Value, manager: &TerminalManager) -> Result<String> {
191    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
192    let wait_type = require_str(args, "wait_type").map_err(tool_error_to_anyhow)?;
193    let timeout_ms = args
194        .get("timeout_ms")
195        .and_then(|value| value.as_u64())
196        .unwrap_or(30_000);
197
198    let start = Instant::now();
199    let matched = match wait_type.as_str() {
200        "output_contains" => {
201            let needle = require_str(args, "text").map_err(tool_error_to_anyhow)?;
202            wait_for_output_contains(manager, &name, &needle, timeout_ms).await?
203        }
204        "idle" => {
205            let idle_ms = args
206                .get("idle_ms")
207                .and_then(|value| value.as_u64())
208                .unwrap_or(1_000);
209            wait_for_idle(manager, &name, idle_ms, timeout_ms).await?
210        }
211        other => {
212            return Err(anyhow!(
213                "unsupported wait_type '{}' (supported: output_contains, idle)",
214                other
215            ));
216        }
217    };
218
219    let terminal = manager.get(&name).await;
220    Ok(serde_json::to_string_pretty(&json!({
221        "operation": "wait",
222        "name": name,
223        "wait_type": wait_type,
224        "matched": matched,
225        "elapsed_ms": start.elapsed().as_millis() as u64,
226        "terminal": terminal,
227    }))?)
228}
229
230async fn execute_close(args: &Value, manager: &TerminalManager) -> Result<String> {
231    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
232    manager.remove(&name).await?;
233    Ok(serde_json::to_string_pretty(&json!({
234        "operation": "close",
235        "name": name,
236        "closed": true,
237    }))?)
238}
239
240async fn execute_list(manager: &TerminalManager) -> Result<String> {
241    let terminals = manager.list().await;
242    Ok(serde_json::to_string_pretty(&json!({
243        "operation": "list",
244        "terminals": terminals,
245    }))?)
246}
247
248async fn execute_reset_command_state(args: &Value, manager: &TerminalManager) -> Result<String> {
249    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
250    manager.reset_command_state(&name).await?;
251    let terminal = manager
252        .get(&name)
253        .await
254        .ok_or_else(|| anyhow!("terminal session '{}' vanished after reset", name))?;
255    Ok(serde_json::to_string_pretty(&json!({
256        "operation": "reset_command_state",
257        "terminal": terminal,
258    }))?)
259}
260
261async fn execute_touch(args: &Value, manager: &TerminalManager) -> Result<String> {
262    let name = require_str(args, "name").map_err(tool_error_to_anyhow)?;
263    manager.touch_output_activity(&name).await?;
264    let terminal = manager
265        .get(&name)
266        .await
267        .ok_or_else(|| anyhow!("terminal session '{}' vanished after touch", name))?;
268    Ok(serde_json::to_string_pretty(&json!({
269        "operation": "touch",
270        "terminal": terminal,
271    }))?)
272}
273
274async fn execute_cleanup_ephemeral(args: &Value, manager: &TerminalManager) -> Result<String> {
275    let min_idle_ms = args
276        .get("min_idle_ms")
277        .and_then(|value| value.as_u64())
278        .unwrap_or(0);
279    let removed = manager
280        .close_ephemeral_idle_older_than(Duration::from_millis(min_idle_ms))
281        .await;
282    Ok(serde_json::to_string_pretty(&json!({
283        "operation": "cleanup_ephemeral",
284        "removed": removed,
285    }))?)
286}
287
288fn require_u16(args: &Value, key: &str) -> Result<u16> {
289    let value = args
290        .get(key)
291        .and_then(|value| value.as_u64())
292        .ok_or_else(|| anyhow!("missing required argument '{}'", key))?;
293    u16::try_from(value).map_err(|_| anyhow!("argument '{}' must fit in u16", key))
294}
295
296async fn wait_for_output_contains(
297    manager: &TerminalManager,
298    name: &str,
299    needle: &str,
300    timeout_ms: u64,
301) -> Result<bool> {
302    let deadline = Instant::now() + Duration::from_millis(timeout_ms);
303    loop {
304        let info = manager
305            .get(name)
306            .await
307            .ok_or_else(|| anyhow!("terminal session '{}' not found", name))?;
308        let history = manager.read_history(name).await?;
309        if history.contains(needle) {
310            return Ok(true);
311        }
312        if !info.alive {
313            return Ok(false);
314        }
315        if Instant::now() >= deadline {
316            return Ok(false);
317        }
318        tokio::time::sleep(Duration::from_millis(50)).await;
319    }
320}
321
322async fn wait_for_idle(
323    manager: &TerminalManager,
324    name: &str,
325    idle_ms: u64,
326    timeout_ms: u64,
327) -> Result<bool> {
328    let deadline = Instant::now() + Duration::from_millis(timeout_ms);
329    let idle_duration = Duration::from_millis(idle_ms);
330    loop {
331        let info = manager
332            .get(name)
333            .await
334            .ok_or_else(|| anyhow!("terminal session '{}' not found", name))?;
335        if info.idle_ms >= idle_duration.as_millis() as u64 {
336            return Ok(true);
337        }
338        if Instant::now() >= deadline {
339            return Ok(false);
340        }
341        tokio::time::sleep(Duration::from_millis(50)).await;
342    }
343}
344
345fn tail_lines(text: &str, count: usize) -> String {
346    if count == 0 {
347        return String::new();
348    }
349    let lines: Vec<&str> = text.lines().collect();
350    let start = lines.len().saturating_sub(count);
351    lines[start..].join("\n")
352}
353
354fn tool_error_to_anyhow(error: ToolResult) -> anyhow::Error {
355    anyhow!(error.content)
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::events::EventSink;
362    use serde_json::json;
363    use std::collections::HashSet;
364    use std::sync::Arc;
365    use tokio::sync::Mutex;
366
367    fn test_runtime() -> ToolRuntime {
368        ToolRuntime {
369            store_path: PathBuf::new(),
370            session_id: None,
371            worker_executable: None,
372            active_threads: Arc::new(Mutex::new(HashSet::new())),
373            event_sink: EventSink::none(),
374            sandbox: None,
375            mcp: None,
376            skills: None,
377            activated_skills: Arc::new(Mutex::new(HashSet::new())),
378            terminal_manager: crate::terminal::TerminalManager::new(),
379            thread_timeout_secs: crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS,
380        }
381    }
382
383    #[tokio::test]
384    async fn terminal_definition_shape() {
385        let def = terminal_definition();
386        assert_eq!(def.function.name, "terminal");
387        assert!(def
388            .function
389            .description
390            .contains("named persistent terminal sessions"));
391    }
392
393    #[tokio::test]
394    async fn terminal_create_and_list_round_trip() {
395        let runtime = test_runtime();
396        let created = execute_terminal(
397            &json!({ "operation": "create", "name": "named-shell" }),
398            &runtime,
399        )
400        .await
401        .unwrap();
402        assert!(created.contains("named-shell"), "got: {}", created);
403
404        let listed = execute_terminal(&json!({ "operation": "list" }), &runtime)
405            .await
406            .unwrap();
407        assert!(listed.contains("named-shell"), "got: {}", listed);
408
409        runtime
410            .terminal_manager
411            .remove("named-shell")
412            .await
413            .unwrap();
414    }
415
416    #[tokio::test]
417    async fn terminal_send_read_resize_and_close_round_trip() {
418        let runtime = test_runtime();
419        execute_terminal(
420            &json!({ "operation": "create", "name": "ops-shell", "cols": 80, "rows": 24 }),
421            &runtime,
422        )
423        .await
424        .unwrap();
425
426        let sent = execute_terminal(
427            &json!({
428                "operation": "send",
429                "name": "ops-shell",
430                "input": "echo named-terminal<RET>",
431                "yield_time_ms": 2000
432            }),
433            &runtime,
434        )
435        .await
436        .unwrap();
437        assert!(sent.contains("named-terminal"), "got: {}", sent);
438
439        let read = execute_terminal(
440            &json!({ "operation": "read", "name": "ops-shell", "lines": 20 }),
441            &runtime,
442        )
443        .await
444        .unwrap();
445        assert!(read.contains("named-terminal"), "got: {}", read);
446
447        let resized = execute_terminal(
448            &json!({ "operation": "resize", "name": "ops-shell", "cols": 100, "rows": 35 }),
449            &runtime,
450        )
451        .await
452        .unwrap();
453        assert!(resized.contains("100"), "got: {}", resized);
454        assert!(resized.contains("35"), "got: {}", resized);
455
456        let closed = execute_terminal(
457            &json!({ "operation": "close", "name": "ops-shell" }),
458            &runtime,
459        )
460        .await
461        .unwrap();
462        assert!(closed.contains("closed"), "got: {}", closed);
463    }
464
465    #[tokio::test]
466    async fn terminal_wait_supports_output_contains_and_idle() {
467        let runtime = test_runtime();
468        execute_terminal(
469            &json!({ "operation": "create", "name": "wait-shell" }),
470            &runtime,
471        )
472        .await
473        .unwrap();
474        execute_terminal(
475            &json!({
476                "operation": "send",
477                "name": "wait-shell",
478                "input": "echo wait-marker<RET>",
479                "yield_time_ms": 500
480            }),
481            &runtime,
482        )
483        .await
484        .unwrap();
485
486        let output_wait = execute_terminal(
487            &json!({
488                "operation": "wait",
489                "name": "wait-shell",
490                "wait_type": "output_contains",
491                "text": "wait-marker",
492                "timeout_ms": 2000
493            }),
494            &runtime,
495        )
496        .await
497        .unwrap();
498        assert!(
499            output_wait.contains("\"matched\": true"),
500            "got: {}",
501            output_wait
502        );
503
504        let idle_wait = execute_terminal(
505            &json!({
506                "operation": "wait",
507                "name": "wait-shell",
508                "wait_type": "idle",
509                "idle_ms": 50,
510                "timeout_ms": 2000
511            }),
512            &runtime,
513        )
514        .await
515        .unwrap();
516        assert!(
517            idle_wait.contains("\"matched\": true"),
518            "got: {}",
519            idle_wait
520        );
521
522        runtime.terminal_manager.remove("wait-shell").await.unwrap();
523    }
524
525    #[tokio::test]
526    #[ignore]
527    async fn terminal_wait_output_contains_returns_false_after_terminal_exit() {
528        let runtime = test_runtime();
529        execute_terminal(
530            &json!({ "operation": "create", "name": "wait-miss-shell" }),
531            &runtime,
532        )
533        .await
534        .unwrap();
535        execute_terminal(
536            &json!({
537                "operation": "send",
538                "name": "wait-miss-shell",
539                "input": "exit<RET>",
540                "yield_time_ms": 500
541            }),
542            &runtime,
543        )
544        .await
545        .unwrap();
546
547        let waited = execute_terminal(
548            &json!({
549                "operation": "wait",
550                "name": "wait-miss-shell",
551                "wait_type": "output_contains",
552                "text": "definitely-not-present",
553                "timeout_ms": 500
554            }),
555            &runtime,
556        )
557        .await
558        .unwrap();
559        assert!(waited.contains("\"matched\": false"), "got: {}", waited);
560    }
561
562    #[tokio::test]
563    async fn terminal_can_reset_completed_command_state() {
564        let runtime = test_runtime();
565        execute_terminal(
566            &json!({ "operation": "create", "name": "reset-shell" }),
567            &runtime,
568        )
569        .await
570        .unwrap();
571
572        execute_terminal(
573            &json!({
574                "operation": "send",
575                "name": "reset-shell",
576                "input": "exit<RET>",
577                "yield_time_ms": 500
578            }),
579            &runtime,
580        )
581        .await
582        .unwrap();
583
584        let reset = execute_terminal(
585            &json!({ "operation": "reset_command_state", "name": "reset-shell" }),
586            &runtime,
587        )
588        .await;
589        assert!(reset.is_err() || reset.as_ref().unwrap().contains("reset_command_state"));
590    }
591
592    #[tokio::test]
593    async fn cleanup_ephemeral_removes_finished_exec_command_sessions() {
594        let runtime = test_runtime();
595        let created = crate::tools::exec_command::execute_exec_command(
596            &json!({ "cmd": "", "tty": true, "yield_time_ms": 500 }),
597            &runtime,
598        )
599        .await
600        .unwrap();
601        let created_json: Value = serde_json::from_str(&created).unwrap();
602        let session_name = created_json["session_name"].as_str().unwrap().to_string();
603        crate::tools::exec_command::execute_write_stdin(
604            &json!({ "session_id": session_name, "chars": "exit<RET>", "yield_time_ms": 500 }),
605            &runtime,
606        )
607        .await
608        .unwrap();
609
610        let cleanup = execute_terminal(
611            &json!({ "operation": "cleanup_ephemeral", "min_idle_ms": 0 }),
612            &runtime,
613        )
614        .await
615        .unwrap();
616        assert!(cleanup.contains("cleanup_ephemeral"), "got: {}", cleanup);
617    }
618
619    #[tokio::test]
620    async fn named_and_ephemeral_sessions_can_coexist() {
621        let runtime = test_runtime();
622        execute_terminal(
623            &json!({ "operation": "create", "name": "coexist-named" }),
624            &runtime,
625        )
626        .await
627        .unwrap();
628
629        let ephemeral = crate::tools::exec_command::execute_exec_command(
630            &json!({ "cmd": "echo coexist", "tty": true, "yield_time_ms": 500 }),
631            &runtime,
632        )
633        .await
634        .unwrap();
635        let ephemeral_json: Value = serde_json::from_str(&ephemeral).unwrap();
636        let ephemeral_name = ephemeral_json["session_name"].as_str().unwrap().to_string();
637
638        let listed = execute_terminal(&json!({ "operation": "list" }), &runtime)
639            .await
640            .unwrap();
641        assert!(listed.contains("coexist-named"), "got: {}", listed);
642        assert!(listed.contains(&ephemeral_name), "got: {}", listed);
643
644        runtime
645            .terminal_manager
646            .remove("coexist-named")
647            .await
648            .unwrap();
649        runtime
650            .terminal_manager
651            .remove(&ephemeral_name)
652            .await
653            .unwrap();
654    }
655
656    #[tokio::test]
657    async fn terminal_touch_updates_idle_tracking() {
658        let runtime = test_runtime();
659        execute_terminal(
660            &json!({ "operation": "create", "name": "touch-shell" }),
661            &runtime,
662        )
663        .await
664        .unwrap();
665
666        tokio::time::sleep(Duration::from_millis(50)).await;
667        let before = runtime.terminal_manager.get("touch-shell").await.unwrap();
668        execute_terminal(
669            &json!({ "operation": "touch", "name": "touch-shell" }),
670            &runtime,
671        )
672        .await
673        .unwrap();
674        let after = runtime.terminal_manager.get("touch-shell").await.unwrap();
675        assert!(
676            after.idle_ms <= before.idle_ms,
677            "before={}, after={}",
678            before.idle_ms,
679            after.idle_ms
680        );
681
682        runtime
683            .terminal_manager
684            .remove("touch-shell")
685            .await
686            .unwrap();
687    }
688
689    #[tokio::test]
690    async fn terminal_named_create_rejects_duplicate_names() {
691        let runtime = test_runtime();
692        execute_terminal(
693            &json!({ "operation": "create", "name": "duplicate-shell" }),
694            &runtime,
695        )
696        .await
697        .unwrap();
698
699        let duplicate = execute_terminal(
700            &json!({ "operation": "create", "name": "duplicate-shell" }),
701            &runtime,
702        )
703        .await;
704        assert!(duplicate.is_err());
705        assert!(duplicate
706            .unwrap_err()
707            .to_string()
708            .contains("already exists"));
709
710        runtime
711            .terminal_manager
712            .remove("duplicate-shell")
713            .await
714            .unwrap();
715    }
716}