Skip to main content

mixtape_tools/process/
interact_with_process.rs

1use crate::prelude::*;
2use crate::process::start_process::SESSION_MANAGER;
3
4/// Input for interacting with a process
5#[derive(Debug, Deserialize, JsonSchema)]
6pub struct InteractWithProcessInput {
7    /// Process ID to interact with
8    pub pid: u32,
9
10    /// Input to send to the process (will be followed by a newline)
11    pub input: String,
12
13    /// Wait for response after sending input (default: true)
14    #[serde(default = "default_wait")]
15    pub wait_for_response: bool,
16
17    /// Maximum time to wait for response in milliseconds (default: 5000)
18    #[serde(default = "default_response_timeout")]
19    pub response_timeout_ms: u64,
20}
21
22fn default_wait() -> bool {
23    true
24}
25
26fn default_response_timeout() -> u64 {
27    5000
28}
29
30/// Tool for sending input to a running process
31pub struct InteractWithProcessTool;
32
33impl Tool for InteractWithProcessTool {
34    type Input = InteractWithProcessInput;
35
36    fn name(&self) -> &str {
37        "interact_with_process"
38    }
39
40    fn description(&self) -> &str {
41        "Send input to a running process and optionally wait for its response. Useful for interactive programs."
42    }
43
44    fn format_output_plain(&self, result: &ToolResult) -> String {
45        let text = result.as_text();
46        let (pid, input_sent, status, response) = parse_interact_output(&text);
47
48        let mut out = String::new();
49        out.push_str(&"─".repeat(50));
50        out.push('\n');
51        if let Some(p) = pid {
52            out.push_str(&format!("  Process {} ", p));
53        }
54        if let Some(s) = status {
55            out.push_str(&format!("[{}]", s));
56        }
57        out.push_str(&format!("\n{}\n", "─".repeat(50)));
58        if let Some(cmd) = input_sent {
59            out.push_str(&format!("  >>> {}\n", cmd));
60        }
61        if !response.is_empty() {
62            out.push_str(&"─".repeat(50));
63            out.push('\n');
64            for line in response {
65                out.push_str(&format!("  {}\n", line));
66            }
67        }
68        out
69    }
70
71    fn format_output_ansi(&self, result: &ToolResult) -> String {
72        let text = result.as_text();
73        let (pid, input_sent, status, response) = parse_interact_output(&text);
74
75        let mut out = String::new();
76        out.push_str(&format!("\x1b[2m{}\x1b[0m\n", "─".repeat(50)));
77
78        let (icon, status_color) = match status {
79            Some(s) if s.contains("Running") => ("\x1b[32m●\x1b[0m", "\x1b[32m"),
80            Some(s) if s.contains("Completed") => ("\x1b[34m●\x1b[0m", "\x1b[34m"),
81            Some(s) if s.contains("Waiting") => ("\x1b[33m●\x1b[0m", "\x1b[33m"),
82            _ => ("\x1b[2m●\x1b[0m", "\x1b[2m"),
83        };
84
85        out.push_str(&format!("  {} ", icon));
86        if let Some(p) = pid {
87            out.push_str(&format!("\x1b[1mProcess {}\x1b[0m ", p));
88        }
89        if let Some(s) = status {
90            out.push_str(&format!("{}{}\x1b[0m", status_color, s));
91        }
92        out.push_str(&format!("\n\x1b[2m{}\x1b[0m\n", "─".repeat(50)));
93        if let Some(cmd) = input_sent {
94            out.push_str(&format!("  \x1b[33m>>>\x1b[0m \x1b[36m{}\x1b[0m\n", cmd));
95        }
96        if !response.is_empty() {
97            out.push_str(&format!("\x1b[2m{}\x1b[0m\n", "─".repeat(50)));
98            for line in response {
99                out.push_str(&format!("  \x1b[2m│\x1b[0m {}\n", line));
100            }
101        }
102        out
103    }
104
105    fn format_output_markdown(&self, result: &ToolResult) -> String {
106        let text = result.as_text();
107        let (pid, input_sent, status, response) = parse_interact_output(&text);
108
109        let mut out = String::new();
110        let status_emoji = match status {
111            Some(s) if s.contains("Running") => "🟢",
112            Some(s) if s.contains("Completed") => "🔵",
113            Some(s) if s.contains("Waiting") => "🟡",
114            _ => "⚪",
115        };
116
117        if let Some(p) = pid {
118            out.push_str(&format!("### {} Process {}", status_emoji, p));
119        }
120        if let Some(s) = status {
121            out.push_str(&format!(" - {}", s));
122        }
123        out.push_str("\n\n");
124        if let Some(cmd) = input_sent {
125            out.push_str(&format!("**Input:** `{}`\n\n", cmd));
126        }
127        if !response.is_empty() {
128            out.push_str("**Response:**\n```\n");
129            for line in response {
130                out.push_str(line);
131                out.push('\n');
132            }
133            out.push_str("```\n");
134        }
135        out
136    }
137
138    async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
139        use crate::process::session_manager::ProcessState;
140
141        let manager = SESSION_MANAGER.lock().await;
142
143        // Send input
144        manager.send_input(input.pid, &input.input).await?;
145
146        if !input.wait_for_response {
147            return Ok(format!("Sent input to process {}: {}", input.pid, input.input).into());
148        }
149
150        // Clear buffer before waiting
151        let _ = manager.read_output(input.pid, true).await;
152
153        drop(manager);
154
155        // Poll for response with early exit on prompt detection
156        let timeout_ms = input.response_timeout_ms.min(10000);
157        let poll_interval_ms = 50;
158        let max_polls = timeout_ms / poll_interval_ms;
159        let mut exit_reason = "timeout";
160
161        for _ in 0..max_polls {
162            tokio::time::sleep(tokio::time::Duration::from_millis(poll_interval_ms)).await;
163
164            let manager = SESSION_MANAGER.lock().await;
165            let status = manager.check_status(input.pid).await?;
166
167            match status {
168                ProcessState::WaitingForInput => {
169                    exit_reason = "prompt_detected";
170                    break;
171                }
172                ProcessState::Completed { .. } => {
173                    exit_reason = "process_exited";
174                    break;
175                }
176                ProcessState::TimedOut => {
177                    exit_reason = "process_timeout";
178                    break;
179                }
180                ProcessState::Running => {
181                    // Continue polling
182                }
183            }
184        }
185
186        let manager = SESSION_MANAGER.lock().await;
187        let output = manager.read_output(input.pid, false).await?;
188        let status = manager.check_status(input.pid).await?;
189
190        let content = format!(
191            "Sent to process {}: {}\nStatus: {:?} ({})\n\nResponse ({} lines):\n{}",
192            input.pid,
193            input.input,
194            status,
195            exit_reason,
196            output.len(),
197            output.join("\n")
198        );
199
200        Ok(content.into())
201    }
202}
203
204/// Parse interact output
205fn parse_interact_output(output: &str) -> (Option<&str>, Option<&str>, Option<&str>, Vec<&str>) {
206    let mut pid = None;
207    let mut input_sent = None;
208    let mut status = None;
209    let mut response_lines = Vec::new();
210    let mut in_response = false;
211
212    for line in output.lines() {
213        if line.starts_with("Sent to process ") {
214            // "Sent to process 1234: command"
215            let rest = line.trim_start_matches("Sent to process ");
216            if let Some(colon_idx) = rest.find(':') {
217                pid = Some(&rest[..colon_idx]);
218                input_sent = Some(rest[colon_idx + 1..].trim());
219            }
220        } else if line.starts_with("Sent input to process ") {
221            // Short form: "Sent input to process 1234: command"
222            let rest = line.trim_start_matches("Sent input to process ");
223            if let Some(colon_idx) = rest.find(':') {
224                pid = Some(&rest[..colon_idx]);
225                input_sent = Some(rest[colon_idx + 1..].trim());
226            }
227        } else if line.starts_with("Status:") {
228            status = Some(line.trim_start_matches("Status:").trim());
229        } else if line.starts_with("Response (") {
230            in_response = true;
231        } else if in_response {
232            response_lines.push(line);
233        }
234    }
235
236    (pid, input_sent, status, response_lines)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::process::start_process::{StartProcessInput, StartProcessTool};
243    use mixtape_core::ToolResult;
244
245    #[tokio::test]
246    async fn test_interact_with_process_nonexistent() {
247        let tool = InteractWithProcessTool;
248
249        let input = InteractWithProcessInput {
250            pid: 99999999,
251            input: "test".to_string(),
252            wait_for_response: false,
253            response_timeout_ms: 100,
254        };
255
256        let result = tool.execute(input).await;
257        assert!(result.is_err());
258    }
259
260    #[tokio::test]
261    async fn test_interact_with_process_no_wait() {
262        // Start a process
263        let start_tool = StartProcessTool;
264        let start_input = StartProcessInput {
265            command: "cat".to_string(), // cat reads stdin
266            timeout_ms: Some(5000),
267            shell: None,
268        };
269
270        let start_result = start_tool.execute(start_input).await;
271        if start_result.is_err() {
272            // Skip if process creation fails
273            return;
274        }
275
276        let start_output = start_result.unwrap().as_text();
277        // Extract PID
278        if let Some(pid_line) = start_output.lines().find(|l| l.contains("PID:")) {
279            if let Some(pid_str) = pid_line.split(':').nth(1) {
280                if let Ok(pid) = pid_str.trim().parse::<u32>() {
281                    // Send input without waiting for response
282                    let interact_tool = InteractWithProcessTool;
283                    let interact_input = InteractWithProcessInput {
284                        pid,
285                        input: "hello".to_string(),
286                        wait_for_response: false,
287                        response_timeout_ms: 100,
288                    };
289
290                    let result = interact_tool.execute(interact_input).await;
291                    assert!(result.is_ok());
292                    let output = result.unwrap().as_text();
293                    assert!(output.contains("Sent input to process"));
294                    return;
295                }
296            }
297        }
298    }
299
300    #[tokio::test]
301    async fn test_interact_with_process_with_wait() {
302        // Start an interactive cat process
303        let start_tool = StartProcessTool;
304        let start_input = StartProcessInput {
305            command: "cat".to_string(),
306            timeout_ms: Some(5000),
307            shell: None,
308        };
309
310        let start_result = start_tool.execute(start_input).await;
311        if start_result.is_err() {
312            return;
313        }
314
315        let start_output = start_result.unwrap().as_text();
316        if let Some(pid_line) = start_output.lines().find(|l| l.contains("PID:")) {
317            if let Some(pid_str) = pid_line.split(':').nth(1) {
318                if let Ok(pid) = pid_str.trim().parse::<u32>() {
319                    // Send input and wait for response
320                    let interact_tool = InteractWithProcessTool;
321                    let interact_input = InteractWithProcessInput {
322                        pid,
323                        input: "echo test".to_string(),
324                        wait_for_response: true,
325                        response_timeout_ms: 500,
326                    };
327
328                    let result = interact_tool.execute(interact_input).await;
329                    assert!(result.is_ok());
330                    let output = result.unwrap().as_text();
331                    assert!(output.contains("Sent to process"));
332                    assert!(output.contains("Response"));
333                    return;
334                }
335            }
336        }
337    }
338
339    // ==================== parse_interact_output tests ====================
340
341    #[test]
342    fn test_parse_interact_output_complete() {
343        let output = "Sent to process 12345: hello\nStatus: Running (prompt_detected)\n\nResponse (2 lines):\nworld\nmore";
344        let (pid, input_sent, status, lines) = parse_interact_output(output);
345
346        assert_eq!(pid, Some("12345"));
347        assert_eq!(input_sent, Some("hello"));
348        assert_eq!(status, Some("Running (prompt_detected)"));
349        assert_eq!(lines, vec!["world", "more"]);
350    }
351
352    #[test]
353    fn test_parse_interact_output_short_form() {
354        let output = "Sent input to process 12345: test command";
355        let (pid, input_sent, status, lines) = parse_interact_output(output);
356
357        assert_eq!(pid, Some("12345"));
358        assert_eq!(input_sent, Some("test command"));
359        assert_eq!(status, None);
360        assert!(lines.is_empty());
361    }
362
363    #[test]
364    fn test_parse_interact_output_empty() {
365        let output = "";
366        let (pid, input_sent, status, lines) = parse_interact_output(output);
367
368        assert_eq!(pid, None);
369        assert_eq!(input_sent, None);
370        assert_eq!(status, None);
371        assert!(lines.is_empty());
372    }
373
374    #[test]
375    fn test_parse_interact_output_with_multiline_response() {
376        let output = "Sent to process 1: cmd\nStatus: Completed\n\nResponse (3 lines):\na\nb\nc";
377        let (_, _, _, lines) = parse_interact_output(output);
378
379        assert_eq!(lines.len(), 3);
380        assert_eq!(lines, vec!["a", "b", "c"]);
381    }
382
383    // ==================== format_output tests ====================
384
385    #[test]
386    fn test_format_output_plain_basic() {
387        let tool = InteractWithProcessTool;
388        let result: ToolResult =
389            "Sent to process 12345: hello\nStatus: Running\n\nResponse (1 lines):\nworld".into();
390
391        let formatted = tool.format_output_plain(&result);
392
393        assert!(formatted.contains("Process 12345"));
394        assert!(formatted.contains(">>> hello"));
395        assert!(formatted.contains("world"));
396    }
397
398    #[test]
399    fn test_format_output_plain_no_response() {
400        let tool = InteractWithProcessTool;
401        let result: ToolResult = "Sent input to process 12345: test".into();
402
403        let formatted = tool.format_output_plain(&result);
404
405        assert!(formatted.contains("Process 12345"));
406        assert!(formatted.contains(">>> test"));
407    }
408
409    #[test]
410    fn test_format_output_ansi_running() {
411        let tool = InteractWithProcessTool;
412        let result: ToolResult =
413            "Sent to process 12345: hello\nStatus: Running\n\nResponse (1 lines):\nworld".into();
414
415        let formatted = tool.format_output_ansi(&result);
416
417        assert!(formatted.contains("\x1b[")); // ANSI codes
418        assert!(formatted.contains("\x1b[32m")); // green for running
419        assert!(formatted.contains("\x1b[33m")); // yellow for >>>
420        assert!(formatted.contains("\x1b[36m")); // cyan for input
421    }
422
423    #[test]
424    fn test_format_output_ansi_completed() {
425        let tool = InteractWithProcessTool;
426        let result: ToolResult =
427            "Sent to process 12345: hello\nStatus: Completed\n\nResponse (1 lines):\ndone".into();
428
429        let formatted = tool.format_output_ansi(&result);
430
431        assert!(formatted.contains("\x1b[34m")); // blue for completed
432    }
433
434    #[test]
435    fn test_format_output_markdown_with_response() {
436        let tool = InteractWithProcessTool;
437        let result: ToolResult =
438            "Sent to process 12345: hello\nStatus: Running\n\nResponse (2 lines):\nline1\nline2"
439                .into();
440
441        let formatted = tool.format_output_markdown(&result);
442
443        assert!(formatted.contains("### 🟢 Process 12345"));
444        assert!(formatted.contains("**Input:** `hello`"));
445        assert!(formatted.contains("**Response:**"));
446        assert!(formatted.contains("```"));
447    }
448
449    #[test]
450    fn test_format_output_markdown_status_emojis() {
451        let tool = InteractWithProcessTool;
452
453        // Running
454        let running: ToolResult =
455            "Sent to process 1: x\nStatus: Running\n\nResponse (0 lines):".into();
456        assert!(tool.format_output_markdown(&running).contains("🟢"));
457
458        // Completed
459        let completed: ToolResult =
460            "Sent to process 1: x\nStatus: Completed\n\nResponse (0 lines):".into();
461        assert!(tool.format_output_markdown(&completed).contains("🔵"));
462
463        // Waiting
464        let waiting: ToolResult =
465            "Sent to process 1: x\nStatus: WaitingForInput\n\nResponse (0 lines):".into();
466        assert!(tool.format_output_markdown(&waiting).contains("🟡"));
467    }
468
469    // ==================== default value tests ====================
470
471    #[test]
472    fn test_default_wait() {
473        assert!(default_wait());
474    }
475
476    #[test]
477    fn test_default_response_timeout() {
478        assert_eq!(default_response_timeout(), 5000);
479    }
480
481    // ==================== Tool metadata tests ====================
482
483    #[test]
484    fn test_tool_name() {
485        let tool = InteractWithProcessTool;
486        assert_eq!(tool.name(), "interact_with_process");
487    }
488
489    #[test]
490    fn test_tool_description() {
491        let tool = InteractWithProcessTool;
492        assert!(!tool.description().is_empty());
493        assert!(tool.description().contains("input"));
494    }
495}