Port terminal
tool to agent2 (#35918)
Release Notes: - N/A --------- Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
parent
422e0a2eb7
commit
086ea3c619
13 changed files with 882 additions and 112 deletions
|
@ -33,6 +33,7 @@ language_model.workspace = true
|
|||
language_models.workspace = true
|
||||
log.workspace = true
|
||||
paths.workspace = true
|
||||
portable-pty.workspace = true
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
@ -41,16 +42,20 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
task.workspace = true
|
||||
terminal.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
watch.workspace = true
|
||||
which.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ctor.workspace = true
|
||||
client = { workspace = true, "features" = ["test-support"] }
|
||||
clock = { workspace = true, "features" = ["test-support"] }
|
||||
editor = { workspace = true, "features" = ["test-support"] }
|
||||
env_logger.workspace = true
|
||||
fs = { workspace = true, "features" = ["test-support"] }
|
||||
gpui = { workspace = true, "features" = ["test-support"] }
|
||||
|
@ -58,8 +63,11 @@ gpui_tokio.workspace = true
|
|||
language = { workspace = true, "features" = ["test-support"] }
|
||||
language_model = { workspace = true, "features" = ["test-support"] }
|
||||
lsp = { workspace = true, "features" = ["test-support"] }
|
||||
pretty_assertions.workspace = true
|
||||
project = { workspace = true, "features" = ["test-support"] }
|
||||
reqwest_client.workspace = true
|
||||
settings = { workspace = true, "features" = ["test-support"] }
|
||||
terminal = { workspace = true, "features" = ["test-support"] }
|
||||
theme = { workspace = true, "features" = ["test-support"] }
|
||||
worktree = { workspace = true, "features" = ["test-support"] }
|
||||
pretty_assertions.workspace = true
|
||||
zlog.workspace = true
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
use crate::{AgentResponseEvent, Thread, templates::Templates};
|
||||
use crate::{EditFileTool, FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization};
|
||||
use crate::{
|
||||
EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
|
||||
};
|
||||
use acp_thread::ModelSelector;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
|
@ -418,6 +420,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
|||
thread.add_tool(FindPathTool::new(project.clone()));
|
||||
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
||||
thread.add_tool(EditFileTool::new(cx.entity()));
|
||||
thread.add_tool(TerminalTool::new(project.clone(), cx));
|
||||
thread
|
||||
});
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
use crate::{SystemPromptTemplate, Template, Templates};
|
||||
use acp_thread::Diff;
|
||||
use action_log::ActionLog;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
|
@ -802,47 +801,6 @@ impl AgentResponseEventStream {
|
|||
.ok();
|
||||
}
|
||||
|
||||
fn authorize_tool_call(
|
||||
&self,
|
||||
id: &LanguageModelToolUseId,
|
||||
title: String,
|
||||
kind: acp::ToolKind,
|
||||
input: serde_json::Value,
|
||||
) -> impl use<> + Future<Output = Result<()>> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
|
||||
ToolCallAuthorization {
|
||||
tool_call: Self::initial_tool_call(id, title, kind, input),
|
||||
options: vec![
|
||||
acp::PermissionOption {
|
||||
id: acp::PermissionOptionId("always_allow".into()),
|
||||
name: "Always Allow".into(),
|
||||
kind: acp::PermissionOptionKind::AllowAlways,
|
||||
},
|
||||
acp::PermissionOption {
|
||||
id: acp::PermissionOptionId("allow".into()),
|
||||
name: "Allow".into(),
|
||||
kind: acp::PermissionOptionKind::AllowOnce,
|
||||
},
|
||||
acp::PermissionOption {
|
||||
id: acp::PermissionOptionId("deny".into()),
|
||||
name: "Deny".into(),
|
||||
kind: acp::PermissionOptionKind::RejectOnce,
|
||||
},
|
||||
],
|
||||
response: response_tx,
|
||||
},
|
||||
)))
|
||||
.ok();
|
||||
async move {
|
||||
match response_rx.await?.0.as_ref() {
|
||||
"allow" | "always_allow" => Ok(()),
|
||||
_ => Err(anyhow!("Permission to run tool denied by user")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_tool_call(
|
||||
&self,
|
||||
id: &LanguageModelToolUseId,
|
||||
|
@ -894,18 +852,6 @@ impl AgentResponseEventStream {
|
|||
.ok();
|
||||
}
|
||||
|
||||
fn update_tool_call_diff(&self, tool_use_id: &LanguageModelToolUseId, diff: Entity<Diff>) {
|
||||
self.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
acp_thread::ToolCallUpdateDiff {
|
||||
id: acp::ToolCallId(tool_use_id.to_string().into()),
|
||||
diff,
|
||||
}
|
||||
.into(),
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn send_stop(&self, reason: StopReason) {
|
||||
match reason {
|
||||
StopReason::EndTurn => {
|
||||
|
@ -979,17 +925,71 @@ impl ToolCallEventStream {
|
|||
.update_tool_call_fields(&self.tool_use_id, fields);
|
||||
}
|
||||
|
||||
pub fn update_diff(&self, diff: Entity<Diff>) {
|
||||
self.stream.update_tool_call_diff(&self.tool_use_id, diff);
|
||||
pub fn update_diff(&self, diff: Entity<acp_thread::Diff>) {
|
||||
self.stream
|
||||
.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
acp_thread::ToolCallUpdateDiff {
|
||||
id: acp::ToolCallId(self.tool_use_id.to_string().into()),
|
||||
diff,
|
||||
}
|
||||
.into(),
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_terminal(&self, terminal: Entity<acp_thread::Terminal>) {
|
||||
self.stream
|
||||
.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
acp_thread::ToolCallUpdateTerminal {
|
||||
id: acp::ToolCallId(self.tool_use_id.to_string().into()),
|
||||
terminal,
|
||||
}
|
||||
.into(),
|
||||
)))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn authorize(&self, title: String) -> impl use<> + Future<Output = Result<()>> {
|
||||
self.stream.authorize_tool_call(
|
||||
&self.tool_use_id,
|
||||
title,
|
||||
self.kind.clone(),
|
||||
self.input.clone(),
|
||||
)
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.stream
|
||||
.0
|
||||
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
|
||||
ToolCallAuthorization {
|
||||
tool_call: AgentResponseEventStream::initial_tool_call(
|
||||
&self.tool_use_id,
|
||||
title,
|
||||
self.kind.clone(),
|
||||
self.input.clone(),
|
||||
),
|
||||
options: vec![
|
||||
acp::PermissionOption {
|
||||
id: acp::PermissionOptionId("always_allow".into()),
|
||||
name: "Always Allow".into(),
|
||||
kind: acp::PermissionOptionKind::AllowAlways,
|
||||
},
|
||||
acp::PermissionOption {
|
||||
id: acp::PermissionOptionId("allow".into()),
|
||||
name: "Allow".into(),
|
||||
kind: acp::PermissionOptionKind::AllowOnce,
|
||||
},
|
||||
acp::PermissionOption {
|
||||
id: acp::PermissionOptionId("deny".into()),
|
||||
name: "Deny".into(),
|
||||
kind: acp::PermissionOptionKind::RejectOnce,
|
||||
},
|
||||
],
|
||||
response: response_tx,
|
||||
},
|
||||
)))
|
||||
.ok();
|
||||
async move {
|
||||
match response_rx.await?.0.as_ref() {
|
||||
"allow" | "always_allow" => Ok(()),
|
||||
_ => Err(anyhow!("Permission to run tool denied by user")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1000,7 +1000,7 @@ pub struct ToolCallEventStreamReceiver(
|
|||
|
||||
#[cfg(test)]
|
||||
impl ToolCallEventStreamReceiver {
|
||||
pub async fn expect_tool_authorization(&mut self) -> ToolCallAuthorization {
|
||||
pub async fn expect_authorization(&mut self) -> ToolCallAuthorization {
|
||||
let event = self.0.next().await;
|
||||
if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event {
|
||||
auth
|
||||
|
@ -1008,6 +1008,18 @@ impl ToolCallEventStreamReceiver {
|
|||
panic!("Expected ToolCallAuthorization but got: {:?}", event);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn expect_terminal(&mut self) -> Entity<acp_thread::Terminal> {
|
||||
let event = self.0.next().await;
|
||||
if let Some(Ok(AgentResponseEvent::ToolCallUpdate(
|
||||
acp_thread::ToolCallUpdate::UpdateTerminal(update),
|
||||
))) = event
|
||||
{
|
||||
update.terminal
|
||||
} else {
|
||||
panic!("Expected terminal but got: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
mod edit_file_tool;
|
||||
mod find_path_tool;
|
||||
mod read_file_tool;
|
||||
mod terminal_tool;
|
||||
mod thinking_tool;
|
||||
|
||||
pub use edit_file_tool::*;
|
||||
pub use find_path_tool::*;
|
||||
pub use read_file_tool::*;
|
||||
pub use terminal_tool::*;
|
||||
pub use thinking_tool::*;
|
||||
|
|
|
@ -942,7 +942,7 @@ mod tests {
|
|||
)
|
||||
});
|
||||
|
||||
let event = stream_rx.expect_tool_authorization().await;
|
||||
let event = stream_rx.expect_authorization().await;
|
||||
assert_eq!(event.tool_call.title, "test 1 (local settings)");
|
||||
|
||||
// Test 2: Path outside project should require confirmation
|
||||
|
@ -959,7 +959,7 @@ mod tests {
|
|||
)
|
||||
});
|
||||
|
||||
let event = stream_rx.expect_tool_authorization().await;
|
||||
let event = stream_rx.expect_authorization().await;
|
||||
assert_eq!(event.tool_call.title, "test 2");
|
||||
|
||||
// Test 3: Relative path without .zed should not require confirmation
|
||||
|
@ -992,7 +992,7 @@ mod tests {
|
|||
cx,
|
||||
)
|
||||
});
|
||||
let event = stream_rx.expect_tool_authorization().await;
|
||||
let event = stream_rx.expect_authorization().await;
|
||||
assert_eq!(event.tool_call.title, "test 4 (local settings)");
|
||||
|
||||
// Test 5: When always_allow_tool_actions is enabled, no confirmation needed
|
||||
|
@ -1088,7 +1088,7 @@ mod tests {
|
|||
});
|
||||
|
||||
if should_confirm {
|
||||
stream_rx.expect_tool_authorization().await;
|
||||
stream_rx.expect_authorization().await;
|
||||
} else {
|
||||
auth.await.unwrap();
|
||||
assert!(
|
||||
|
@ -1192,7 +1192,7 @@ mod tests {
|
|||
});
|
||||
|
||||
if should_confirm {
|
||||
stream_rx.expect_tool_authorization().await;
|
||||
stream_rx.expect_authorization().await;
|
||||
} else {
|
||||
auth.await.unwrap();
|
||||
assert!(
|
||||
|
@ -1276,7 +1276,7 @@ mod tests {
|
|||
});
|
||||
|
||||
if should_confirm {
|
||||
stream_rx.expect_tool_authorization().await;
|
||||
stream_rx.expect_authorization().await;
|
||||
} else {
|
||||
auth.await.unwrap();
|
||||
assert!(
|
||||
|
@ -1339,7 +1339,7 @@ mod tests {
|
|||
)
|
||||
});
|
||||
|
||||
stream_rx.expect_tool_authorization().await;
|
||||
stream_rx.expect_authorization().await;
|
||||
|
||||
// Test outside path with different modes
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
|
@ -1355,7 +1355,7 @@ mod tests {
|
|||
)
|
||||
});
|
||||
|
||||
stream_rx.expect_tool_authorization().await;
|
||||
stream_rx.expect_authorization().await;
|
||||
|
||||
// Test normal path with different modes
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
|
|
489
crates/agent2/src/tools/terminal_tool.rs
Normal file
489
crates/agent2/src/tools/terminal_tool.rs
Normal file
|
@ -0,0 +1,489 @@
|
|||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use futures::{FutureExt as _, future::Shared};
|
||||
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||
use project::{Project, terminals::TerminalKind};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode};
|
||||
|
||||
use crate::{AgentTool, ToolCallEventStream};
|
||||
|
||||
const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
|
||||
|
||||
/// Executes a shell one-liner and returns the combined output.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct TerminalToolInput {
|
||||
/// The one-liner command to execute.
|
||||
command: String,
|
||||
/// Working directory for the command. This must be one of the root directories of the project.
|
||||
cd: String,
|
||||
}
|
||||
|
||||
pub struct TerminalTool {
|
||||
project: Entity<Project>,
|
||||
determine_shell: Shared<Task<String>>,
|
||||
}
|
||||
|
||||
impl TerminalTool {
|
||||
pub fn new(project: Entity<Project>, cx: &mut App) -> Self {
|
||||
let determine_shell = cx.background_spawn(async move {
|
||||
if cfg!(windows) {
|
||||
return get_system_shell();
|
||||
}
|
||||
|
||||
if which::which("bash").is_ok() {
|
||||
log::info!("agent selected bash for terminal tool");
|
||||
"bash".into()
|
||||
} else {
|
||||
let shell = get_system_shell();
|
||||
log::info!("agent selected {shell} for terminal tool");
|
||||
shell
|
||||
}
|
||||
});
|
||||
Self {
|
||||
project,
|
||||
determine_shell: determine_shell.shared(),
|
||||
}
|
||||
}
|
||||
|
||||
fn authorize(
|
||||
&self,
|
||||
input: &TerminalToolInput,
|
||||
event_stream: &ToolCallEventStream,
|
||||
cx: &App,
|
||||
) -> Task<Result<()>> {
|
||||
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
// TODO: do we want to have a special title here?
|
||||
cx.foreground_executor()
|
||||
.spawn(event_stream.authorize(self.initial_title(Ok(input.clone())).to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentTool for TerminalTool {
|
||||
type Input = TerminalToolInput;
|
||||
type Output = String;
|
||||
|
||||
fn name(&self) -> SharedString {
|
||||
"terminal".into()
|
||||
}
|
||||
|
||||
fn kind(&self) -> acp::ToolKind {
|
||||
acp::ToolKind::Execute
|
||||
}
|
||||
|
||||
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
|
||||
if let Ok(input) = input {
|
||||
let mut lines = input.command.lines();
|
||||
let first_line = lines.next().unwrap_or_default();
|
||||
let remaining_line_count = lines.count();
|
||||
match remaining_line_count {
|
||||
0 => MarkdownInlineCode(&first_line).to_string().into(),
|
||||
1 => MarkdownInlineCode(&format!(
|
||||
"{} - {} more line",
|
||||
first_line, remaining_line_count
|
||||
))
|
||||
.to_string()
|
||||
.into(),
|
||||
n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
|
||||
.to_string()
|
||||
.into(),
|
||||
}
|
||||
} else {
|
||||
"Run terminal command".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: Self::Input,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Self::Output>> {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
let working_dir = match working_dir(&input, &self.project, cx) {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => return Task::ready(Err(err)),
|
||||
};
|
||||
let program = self.determine_shell.clone();
|
||||
let command = if cfg!(windows) {
|
||||
format!("$null | & {{{}}}", input.command.replace("\"", "'"))
|
||||
} else if let Some(cwd) = working_dir
|
||||
.as_ref()
|
||||
.and_then(|cwd| cwd.as_os_str().to_str())
|
||||
{
|
||||
// Make sure once we're *inside* the shell, we cd into `cwd`
|
||||
format!("(cd {cwd}; {}) </dev/null", input.command)
|
||||
} else {
|
||||
format!("({}) </dev/null", input.command)
|
||||
};
|
||||
let args = vec!["-c".into(), command];
|
||||
|
||||
let env = match &working_dir {
|
||||
Some(dir) => self.project.update(cx, |project, cx| {
|
||||
project.directory_environment(dir.as_path().into(), cx)
|
||||
}),
|
||||
None => Task::ready(None).shared(),
|
||||
};
|
||||
|
||||
let env = cx.spawn(async move |_| {
|
||||
let mut env = env.await.unwrap_or_default();
|
||||
if cfg!(unix) {
|
||||
env.insert("PAGER".into(), "cat".into());
|
||||
}
|
||||
env
|
||||
});
|
||||
|
||||
let authorize = self.authorize(&input, &event_stream, cx);
|
||||
|
||||
cx.spawn({
|
||||
async move |cx| {
|
||||
authorize.await?;
|
||||
|
||||
let program = program.await;
|
||||
let env = env.await;
|
||||
let terminal = self
|
||||
.project
|
||||
.update(cx, |project, cx| {
|
||||
project.create_terminal(
|
||||
TerminalKind::Task(task::SpawnInTerminal {
|
||||
command: Some(program),
|
||||
args,
|
||||
cwd: working_dir.clone(),
|
||||
env,
|
||||
..Default::default()
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
let acp_terminal = cx.new(|cx| {
|
||||
acp_thread::Terminal::new(
|
||||
input.command.clone(),
|
||||
working_dir.clone(),
|
||||
terminal.clone(),
|
||||
language_registry,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
event_stream.update_terminal(acp_terminal.clone());
|
||||
|
||||
let exit_status = terminal
|
||||
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
|
||||
.await;
|
||||
let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
|
||||
(terminal.get_content(), terminal.total_lines())
|
||||
})?;
|
||||
|
||||
let (processed_content, finished_with_empty_output) = process_content(
|
||||
&content,
|
||||
&input.command,
|
||||
exit_status.map(portable_pty::ExitStatus::from),
|
||||
);
|
||||
|
||||
acp_terminal
|
||||
.update(cx, |terminal, cx| {
|
||||
terminal.finish(
|
||||
exit_status,
|
||||
content.len(),
|
||||
processed_content.len(),
|
||||
content_line_count,
|
||||
finished_with_empty_output,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.log_err();
|
||||
|
||||
Ok(processed_content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn process_content(
|
||||
content: &str,
|
||||
command: &str,
|
||||
exit_status: Option<portable_pty::ExitStatus>,
|
||||
) -> (String, bool) {
|
||||
let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
|
||||
|
||||
let content = if should_truncate {
|
||||
let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
|
||||
while !content.is_char_boundary(end_ix) {
|
||||
end_ix -= 1;
|
||||
}
|
||||
// Don't truncate mid-line, clear the remainder of the last line
|
||||
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
|
||||
&content[..end_ix]
|
||||
} else {
|
||||
content
|
||||
};
|
||||
let content = content.trim();
|
||||
let is_empty = content.is_empty();
|
||||
let content = format!("```\n{content}\n```");
|
||||
let content = if should_truncate {
|
||||
format!(
|
||||
"Command output too long. The first {} bytes:\n\n{content}",
|
||||
content.len(),
|
||||
)
|
||||
} else {
|
||||
content
|
||||
};
|
||||
|
||||
let content = match exit_status {
|
||||
Some(exit_status) if exit_status.success() => {
|
||||
if is_empty {
|
||||
"Command executed successfully.".to_string()
|
||||
} else {
|
||||
content.to_string()
|
||||
}
|
||||
}
|
||||
Some(exit_status) => {
|
||||
if is_empty {
|
||||
format!(
|
||||
"Command \"{command}\" failed with exit code {}.",
|
||||
exit_status.exit_code()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Command \"{command}\" failed with exit code {}.\n\n{content}",
|
||||
exit_status.exit_code()
|
||||
)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
format!(
|
||||
"Command failed or was interrupted.\nPartial output captured:\n\n{}",
|
||||
content,
|
||||
)
|
||||
}
|
||||
};
|
||||
(content, is_empty)
|
||||
}
|
||||
|
||||
fn working_dir(
|
||||
input: &TerminalToolInput,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let project = project.read(cx);
|
||||
let cd = &input.cd;
|
||||
|
||||
if cd == "." || cd == "" {
|
||||
// Accept "." or "" as meaning "the one worktree" if we only have one worktree.
|
||||
let mut worktrees = project.worktrees(cx);
|
||||
|
||||
match worktrees.next() {
|
||||
Some(worktree) => {
|
||||
anyhow::ensure!(
|
||||
worktrees.next().is_none(),
|
||||
"'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
|
||||
);
|
||||
Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
} else {
|
||||
let input_path = Path::new(cd);
|
||||
|
||||
if input_path.is_absolute() {
|
||||
// Absolute paths are allowed, but only if they're in one of the project's worktrees.
|
||||
if project
|
||||
.worktrees(cx)
|
||||
.any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
|
||||
{
|
||||
return Ok(Some(input_path.into()));
|
||||
}
|
||||
} else {
|
||||
if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
|
||||
return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use agent_settings::AgentSettings;
|
||||
use editor::EditorSettings;
|
||||
use fs::RealFs;
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use terminal::terminal_settings::TerminalSettings;
|
||||
use theme::ThemeSettings;
|
||||
use util::test::TempTree;
|
||||
|
||||
use crate::AgentResponseEvent;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
zlog::init_test();
|
||||
|
||||
executor.allow_parking();
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
language::init(cx);
|
||||
Project::init_settings(cx);
|
||||
ThemeSettings::register(cx);
|
||||
TerminalSettings::register(cx);
|
||||
EditorSettings::register(cx);
|
||||
AgentSettings::register(cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
if cfg!(windows) {
|
||||
return;
|
||||
}
|
||||
|
||||
init_test(&executor, cx);
|
||||
|
||||
let fs = Arc::new(RealFs::new(None, executor));
|
||||
let tree = TempTree::new(json!({
|
||||
"project": {},
|
||||
}));
|
||||
let project: Entity<Project> =
|
||||
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
|
||||
|
||||
let input = TerminalToolInput {
|
||||
command: "cat".to_owned(),
|
||||
cd: tree
|
||||
.path()
|
||||
.join("project")
|
||||
.as_path()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
};
|
||||
let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
|
||||
let result = cx
|
||||
.update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
|
||||
|
||||
let auth = event_stream_rx.expect_authorization().await;
|
||||
auth.response.send(auth.options[0].id.clone()).unwrap();
|
||||
event_stream_rx.expect_terminal().await;
|
||||
assert_eq!(result.await.unwrap(), "Command executed successfully.");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
if cfg!(windows) {
|
||||
return;
|
||||
}
|
||||
|
||||
init_test(&executor, cx);
|
||||
|
||||
let fs = Arc::new(RealFs::new(None, executor));
|
||||
let tree = TempTree::new(json!({
|
||||
"project": {},
|
||||
"other-project": {},
|
||||
}));
|
||||
let project: Entity<Project> =
|
||||
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
|
||||
|
||||
let check = |input, expected, cx: &mut TestAppContext| {
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
let result = cx.update(|cx| {
|
||||
Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let event = stream_rx.try_next();
|
||||
if let Ok(Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth)))) = event {
|
||||
auth.response.send(auth.options[0].id.clone()).unwrap();
|
||||
}
|
||||
|
||||
cx.spawn(async move |_| {
|
||||
let output = result.await;
|
||||
assert_eq!(output.ok(), expected);
|
||||
})
|
||||
};
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: ".".into(),
|
||||
},
|
||||
Some(format!(
|
||||
"```\n{}\n```",
|
||||
tree.path().join("project").display()
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: "other-project".into(),
|
||||
},
|
||||
None, // other-project is a dir, but *not* a worktree (yet)
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Absolute path above the worktree root
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: tree.path().to_string_lossy().into(),
|
||||
},
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
project
|
||||
.update(cx, |project, cx| {
|
||||
project.create_worktree(tree.path().join("other-project"), true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: "other-project".into(),
|
||||
},
|
||||
Some(format!(
|
||||
"```\n{}\n```",
|
||||
tree.path().join("other-project").display()
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
|
||||
check(
|
||||
TerminalToolInput {
|
||||
command: "pwd".into(),
|
||||
cd: ".".into(),
|
||||
},
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue