terminal_tool.rs

  1use agent_client_protocol as acp;
  2use anyhow::Result;
  3use futures::FutureExt as _;
  4use gpui::{App, Entity, SharedString, Task};
  5use project::Project;
  6use schemars::JsonSchema;
  7use serde::{Deserialize, Serialize};
  8use std::{
  9    path::{Path, PathBuf},
 10    rc::Rc,
 11    sync::Arc,
 12    time::Duration,
 13};
 14use util::markdown::MarkdownInlineCode;
 15
 16use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream};
 17
 18const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
 19
 20/// Executes a shell one-liner and returns the combined output.
 21///
 22/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.
 23///
 24/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
 25///
 26/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.
 27///
 28/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own.
 29///
 30/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs.
 31///
 32/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
 33#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 34pub struct TerminalToolInput {
 35    /// The one-liner command to execute.
 36    pub command: String,
 37    /// Working directory for the command. This must be one of the root directories of the project.
 38    pub cd: String,
 39    /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed.
 40    pub timeout_ms: Option<u64>,
 41}
 42
 43pub struct TerminalTool {
 44    project: Entity<Project>,
 45    environment: Rc<dyn ThreadEnvironment>,
 46}
 47
 48impl TerminalTool {
 49    pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
 50        Self {
 51            project,
 52            environment,
 53        }
 54    }
 55}
 56
 57impl AgentTool for TerminalTool {
 58    type Input = TerminalToolInput;
 59    type Output = String;
 60
 61    fn name() -> &'static str {
 62        "terminal"
 63    }
 64
 65    fn kind() -> acp::ToolKind {
 66        acp::ToolKind::Execute
 67    }
 68
 69    fn initial_title(
 70        &self,
 71        input: Result<Self::Input, serde_json::Value>,
 72        _cx: &mut App,
 73    ) -> SharedString {
 74        if let Ok(input) = input {
 75            let mut lines = input.command.lines();
 76            let first_line = lines.next().unwrap_or_default();
 77            let remaining_line_count = lines.count();
 78            match remaining_line_count {
 79                0 => MarkdownInlineCode(first_line).to_string().into(),
 80                1 => MarkdownInlineCode(&format!(
 81                    "{} - {} more line",
 82                    first_line, remaining_line_count
 83                ))
 84                .to_string()
 85                .into(),
 86                n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
 87                    .to_string()
 88                    .into(),
 89            }
 90        } else {
 91            "".into()
 92        }
 93    }
 94
 95    fn run(
 96        self: Arc<Self>,
 97        input: Self::Input,
 98        event_stream: ToolCallEventStream,
 99        cx: &mut App,
100    ) -> Task<Result<Self::Output>> {
101        let working_dir = match working_dir(&input, &self.project, cx) {
102            Ok(dir) => dir,
103            Err(err) => return Task::ready(Err(err)),
104        };
105
106        let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx);
107        cx.spawn(async move |cx| {
108            authorize.await?;
109
110            let terminal = self
111                .environment
112                .create_terminal(
113                    input.command.clone(),
114                    working_dir,
115                    Some(COMMAND_OUTPUT_LIMIT),
116                    cx,
117                )
118                .await?;
119
120            let terminal_id = terminal.id(cx)?;
121            event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
122                acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)),
123            ]));
124
125            let timeout = input.timeout_ms.map(Duration::from_millis);
126
127            let mut timed_out = false;
128            let mut user_stopped_via_signal = false;
129            let wait_for_exit = terminal.wait_for_exit(cx)?;
130
131            match timeout {
132                Some(timeout) => {
133                    let timeout_task = cx.background_executor().timer(timeout);
134
135                    futures::select! {
136                        _ = wait_for_exit.clone().fuse() => {},
137                        _ = timeout_task.fuse() => {
138                            timed_out = true;
139                            terminal.kill(cx)?;
140                            wait_for_exit.await;
141                        }
142                        _ = event_stream.cancelled_by_user().fuse() => {
143                            user_stopped_via_signal = true;
144                            terminal.kill(cx)?;
145                            wait_for_exit.await;
146                        }
147                    }
148                }
149                None => {
150                    futures::select! {
151                        _ = wait_for_exit.clone().fuse() => {},
152                        _ = event_stream.cancelled_by_user().fuse() => {
153                            user_stopped_via_signal = true;
154                            terminal.kill(cx)?;
155                            wait_for_exit.await;
156                        }
157                    }
158                }
159            };
160
161            // Check if user stopped - we check both:
162            // 1. The cancellation signal from RunningTurn::cancel (e.g. user pressed main Stop button)
163            // 2. The terminal's user_stopped flag (e.g. user clicked Stop on the terminal card)
164            // Note: user_stopped_via_signal is already set above if we detected cancellation in the select!
165            // but we also check was_cancelled_by_user() for cases where cancellation happened after wait_for_exit completed
166            let user_stopped_via_signal =
167                user_stopped_via_signal || event_stream.was_cancelled_by_user();
168            let user_stopped_via_terminal = terminal.was_stopped_by_user(cx).unwrap_or(false);
169            let user_stopped = user_stopped_via_signal || user_stopped_via_terminal;
170
171            let output = terminal.current_output(cx)?;
172
173            Ok(process_content(
174                output,
175                &input.command,
176                timed_out,
177                user_stopped,
178            ))
179        })
180    }
181}
182
183fn process_content(
184    output: acp::TerminalOutputResponse,
185    command: &str,
186    timed_out: bool,
187    user_stopped: bool,
188) -> String {
189    let content = output.output.trim();
190    let is_empty = content.is_empty();
191
192    let content = format!("```\n{content}\n```");
193    let content = if output.truncated {
194        format!(
195            "Command output too long. The first {} bytes:\n\n{content}",
196            content.len(),
197        )
198    } else {
199        content
200    };
201
202    let content = if user_stopped {
203        if is_empty {
204            "The user stopped this command. No output was captured before stopping.\n\n\
205            Since the user intentionally interrupted this command, ask them what they would like to do next \
206            rather than automatically retrying or assuming something went wrong.".to_string()
207        } else {
208            format!(
209                "The user stopped this command. Output captured before stopping:\n\n{}\n\n\
210                Since the user intentionally interrupted this command, ask them what they would like to do next \
211                rather than automatically retrying or assuming something went wrong.",
212                content
213            )
214        }
215    } else if timed_out {
216        if is_empty {
217            format!("Command \"{command}\" timed out. No output was captured.")
218        } else {
219            format!(
220                "Command \"{command}\" timed out. Output captured before timeout:\n\n{}",
221                content
222            )
223        }
224    } else {
225        let exit_code = output.exit_status.as_ref().and_then(|s| s.exit_code);
226        match exit_code {
227            Some(0) => {
228                if is_empty {
229                    "Command executed successfully.".to_string()
230                } else {
231                    content
232                }
233            }
234            Some(exit_code) => {
235                if is_empty {
236                    format!("Command \"{command}\" failed with exit code {}.", exit_code)
237                } else {
238                    format!(
239                        "Command \"{command}\" failed with exit code {}.\n\n{content}",
240                        exit_code
241                    )
242                }
243            }
244            None => {
245                if is_empty {
246                    "Command terminated unexpectedly. No output was captured.".to_string()
247                } else {
248                    format!(
249                        "Command terminated unexpectedly. Output captured:\n\n{}",
250                        content
251                    )
252                }
253            }
254        }
255    };
256    content
257}
258
259fn working_dir(
260    input: &TerminalToolInput,
261    project: &Entity<Project>,
262    cx: &mut App,
263) -> Result<Option<PathBuf>> {
264    let project = project.read(cx);
265    let cd = &input.cd;
266
267    if cd == "." || cd.is_empty() {
268        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
269        let mut worktrees = project.worktrees(cx);
270
271        match worktrees.next() {
272            Some(worktree) => {
273                anyhow::ensure!(
274                    worktrees.next().is_none(),
275                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
276                );
277                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
278            }
279            None => Ok(None),
280        }
281    } else {
282        let input_path = Path::new(cd);
283
284        if input_path.is_absolute() {
285            // Absolute paths are allowed, but only if they're in one of the project's worktrees.
286            if project
287                .worktrees(cx)
288                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
289            {
290                return Ok(Some(input_path.into()));
291            }
292        } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
293            return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
294        }
295
296        anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_process_content_user_stopped() {
306        let output = acp::TerminalOutputResponse::new("partial output".to_string(), false);
307
308        let result = process_content(output, "cargo build", false, true);
309
310        assert!(
311            result.contains("user stopped"),
312            "Expected 'user stopped' message, got: {}",
313            result
314        );
315        assert!(
316            result.contains("partial output"),
317            "Expected output to be included, got: {}",
318            result
319        );
320        assert!(
321            result.contains("ask them what they would like to do"),
322            "Should instruct agent to ask user, got: {}",
323            result
324        );
325    }
326
327    #[test]
328    fn test_process_content_user_stopped_empty_output() {
329        let output = acp::TerminalOutputResponse::new("".to_string(), false);
330
331        let result = process_content(output, "cargo build", false, true);
332
333        assert!(
334            result.contains("user stopped"),
335            "Expected 'user stopped' message, got: {}",
336            result
337        );
338        assert!(
339            result.contains("No output was captured"),
340            "Expected 'No output was captured', got: {}",
341            result
342        );
343    }
344
345    #[test]
346    fn test_process_content_timed_out() {
347        let output = acp::TerminalOutputResponse::new("build output here".to_string(), false);
348
349        let result = process_content(output, "cargo build", true, false);
350
351        assert!(
352            result.contains("timed out"),
353            "Expected 'timed out' message for timeout, got: {}",
354            result
355        );
356        assert!(
357            result.contains("build output here"),
358            "Expected output to be included, got: {}",
359            result
360        );
361    }
362
363    #[test]
364    fn test_process_content_timed_out_with_empty_output() {
365        let output = acp::TerminalOutputResponse::new("".to_string(), false);
366
367        let result = process_content(output, "sleep 1000", true, false);
368
369        assert!(
370            result.contains("timed out"),
371            "Expected 'timed out' for timeout, got: {}",
372            result
373        );
374        assert!(
375            result.contains("No output was captured"),
376            "Expected 'No output was captured' for empty output, got: {}",
377            result
378        );
379    }
380
381    #[test]
382    fn test_process_content_with_success() {
383        let output = acp::TerminalOutputResponse::new("success output".to_string(), false)
384            .exit_status(acp::TerminalExitStatus::new().exit_code(0));
385
386        let result = process_content(output, "echo hello", false, false);
387
388        assert!(
389            result.contains("success output"),
390            "Expected output to be included, got: {}",
391            result
392        );
393        assert!(
394            !result.contains("failed"),
395            "Success should not say 'failed', got: {}",
396            result
397        );
398    }
399
400    #[test]
401    fn test_process_content_with_success_empty_output() {
402        let output = acp::TerminalOutputResponse::new("".to_string(), false)
403            .exit_status(acp::TerminalExitStatus::new().exit_code(0));
404
405        let result = process_content(output, "true", false, false);
406
407        assert!(
408            result.contains("executed successfully"),
409            "Expected success message for empty output, got: {}",
410            result
411        );
412    }
413
414    #[test]
415    fn test_process_content_with_error_exit() {
416        let output = acp::TerminalOutputResponse::new("error output".to_string(), false)
417            .exit_status(acp::TerminalExitStatus::new().exit_code(1));
418
419        let result = process_content(output, "false", false, false);
420
421        assert!(
422            result.contains("failed with exit code 1"),
423            "Expected failure message, got: {}",
424            result
425        );
426        assert!(
427            result.contains("error output"),
428            "Expected output to be included, got: {}",
429            result
430        );
431    }
432
433    #[test]
434    fn test_process_content_with_error_exit_empty_output() {
435        let output = acp::TerminalOutputResponse::new("".to_string(), false)
436            .exit_status(acp::TerminalExitStatus::new().exit_code(1));
437
438        let result = process_content(output, "false", false, false);
439
440        assert!(
441            result.contains("failed with exit code 1"),
442            "Expected failure message, got: {}",
443            result
444        );
445    }
446
447    #[test]
448    fn test_process_content_unexpected_termination() {
449        let output = acp::TerminalOutputResponse::new("some output".to_string(), false);
450
451        let result = process_content(output, "some_command", false, false);
452
453        assert!(
454            result.contains("terminated unexpectedly"),
455            "Expected 'terminated unexpectedly' message, got: {}",
456            result
457        );
458        assert!(
459            result.contains("some output"),
460            "Expected output to be included, got: {}",
461            result
462        );
463    }
464
465    #[test]
466    fn test_process_content_unexpected_termination_empty_output() {
467        let output = acp::TerminalOutputResponse::new("".to_string(), false);
468
469        let result = process_content(output, "some_command", false, false);
470
471        assert!(
472            result.contains("terminated unexpectedly"),
473            "Expected 'terminated unexpectedly' message, got: {}",
474            result
475        );
476        assert!(
477            result.contains("No output was captured"),
478            "Expected 'No output was captured' for empty output, got: {}",
479            result
480        );
481    }
482}