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}