diff --git a/Cargo.lock b/Cargo.lock index 9ca7a21a7d..fa44c79436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,7 @@ version = "0.1.0" dependencies = [ "acp_thread", "agent-client-protocol", + "agent_settings", "agentic-coding-protocol", "anyhow", "collections", diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index dcffb05bc0..f6721cdbf9 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -19,6 +19,7 @@ doctest = false [dependencies] acp_thread.workspace = true agent-client-protocol.workspace = true +agent_settings.workspace = true agentic-coding-protocol.workspace = true anyhow.workspace = true collections.workspace = true diff --git a/crates/agent_servers/src/claude/mcp_server.rs b/crates/agent_servers/src/claude/mcp_server.rs index cc303016f1..42b482914d 100644 --- a/crates/agent_servers/src/claude/mcp_server.rs +++ b/crates/agent_servers/src/claude/mcp_server.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::claude::tools::{ClaudeTool, EditToolParams, ReadToolParams}; use acp_thread::AcpThread; use agent_client_protocol as acp; +use agent_settings::AgentSettings; use anyhow::{Context, Result}; use collections::HashMap; use context_server::listener::{McpServerTool, ToolResponse}; @@ -13,6 +14,7 @@ use context_server::types::{ use gpui::{App, AsyncApp, Task, WeakEntity}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; pub struct ClaudeZedMcpServer { server: context_server::listener::McpServer, @@ -114,6 +116,7 @@ pub struct PermissionToolParams { #[derive(Serialize)] #[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(serde::Deserialize))] pub struct PermissionToolResponse { behavior: PermissionToolBehavior, updated_input: serde_json::Value, @@ -121,7 +124,8 @@ pub struct PermissionToolResponse { #[derive(Serialize)] #[serde(rename_all = "snake_case")] -enum PermissionToolBehavior { +#[cfg_attr(test, derive(serde::Deserialize))] +pub enum PermissionToolBehavior { Allow, Deny, } @@ -141,6 +145,26 @@ impl McpServerTool for PermissionTool { input: Self::Input, cx: &mut AsyncApp, ) -> Result> { + // Check if we should automatically allow tool actions + let always_allow = + cx.update(|cx| AgentSettings::get_global(cx).always_allow_tool_actions)?; + + if always_allow { + // If always_allow_tool_actions is true, immediately return Allow without prompting + let response = PermissionToolResponse { + behavior: PermissionToolBehavior::Allow, + updated_input: input.input, + }; + + return Ok(ToolResponse { + content: vec![ToolResponseContent::Text { + text: serde_json::to_string(&response)?, + }], + structured_content: (), + }); + } + + // Otherwise, proceed with the normal permission flow let mut thread_rx = self.thread_rx.clone(); let Some(thread) = thread_rx.recv().await?.upgrade() else { anyhow::bail!("Thread closed"); @@ -300,3 +324,78 @@ impl McpServerTool for EditTool { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use project::Project; + use settings::{Settings, SettingsStore}; + + #[gpui::test] + async fn test_permission_tool_respects_always_allow_setting(cx: &mut TestAppContext) { + // Initialize settings + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + agent_settings::init(cx); + }); + + // Create a test thread + let project = cx.update(|cx| gpui::Entity::new(cx, |_cx| Project::local())); + let thread = cx.update(|cx| { + gpui::Entity::new(cx, |_cx| { + acp_thread::AcpThread::new( + acp::ConnectionId("test".into()), + project, + std::path::Path::new("/tmp"), + ) + }) + }); + + let (tx, rx) = watch::channel(thread.downgrade()); + let tool = PermissionTool { thread_rx: rx }; + + // Test with always_allow_tool_actions = true + cx.update(|cx| { + AgentSettings::override_global( + AgentSettings { + always_allow_tool_actions: true, + ..Default::default() + }, + cx, + ); + }); + + let input = PermissionToolParams { + tool_name: "test_tool".to_string(), + input: serde_json::json!({"test": "data"}), + tool_use_id: Some("test_id".to_string()), + }; + + let result = tool.run(input.clone(), &mut cx.to_async()).await.unwrap(); + + // Should return Allow without prompting + assert_eq!(result.content.len(), 1); + if let ToolResponseContent::Text { text } = &result.content[0] { + let response: PermissionToolResponse = serde_json::from_str(text).unwrap(); + assert!(matches!(response.behavior, PermissionToolBehavior::Allow)); + } else { + panic!("Expected text response"); + } + + // Test with always_allow_tool_actions = false + cx.update(|cx| { + AgentSettings::override_global( + AgentSettings { + always_allow_tool_actions: false, + ..Default::default() + }, + cx, + ); + }); + + // This test would require mocking the permission prompt response + // In the real scenario, it would wait for user input + } +} diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index e9c72eabc9..307bc1ad52 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -7,6 +7,7 @@ use std::{ use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol as acp; +use agent_settings::AgentSettings; use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::{Entity, TestAppContext}; @@ -241,6 +242,57 @@ pub async fn test_tool_call_with_confirmation( }); } +pub async fn test_tool_call_always_allow( + server: impl AgentServer + 'static, + cx: &mut TestAppContext, +) { + let fs = init_test(cx).await; + let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; + + // Enable always_allow_tool_actions + cx.update(|cx| { + AgentSettings::override_global( + AgentSettings { + always_allow_tool_actions: true, + ..Default::default() + }, + cx, + ); + }); + + let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let full_turn = thread.update(cx, |thread, cx| { + thread.send_raw( + r#"Run `touch hello.txt && echo "Hello, world!" | tee hello.txt`"#, + cx, + ) + }); + + // Wait for the tool call to complete + full_turn.await.unwrap(); + + thread.read_with(cx, |thread, _cx| { + // With always_allow_tool_actions enabled, the tool call should be immediately allowed + // without waiting for confirmation + let tool_call_entry = thread + .entries() + .iter() + .find(|entry| matches!(entry, AgentThreadEntry::ToolCall(_))) + .expect("Expected a tool call entry"); + + let AgentThreadEntry::ToolCall(tool_call) = tool_call_entry else { + panic!("Expected tool call entry"); + }; + + // Should be allowed, not waiting for confirmation + assert!( + matches!(tool_call.status, ToolCallStatus::Allowed { .. }), + "Expected tool call to be allowed automatically, but got {:?}", + tool_call.status + ); + }); +} + pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) { let fs = init_test(cx).await; @@ -351,6 +403,12 @@ macro_rules! common_e2e_tests { async fn cancel(cx: &mut ::gpui::TestAppContext) { $crate::e2e_tests::test_cancel($server, cx).await; } + + #[::gpui::test] + #[cfg_attr(not(feature = "e2e"), ignore)] + async fn tool_call_always_allow(cx: &mut ::gpui::TestAppContext) { + $crate::e2e_tests::test_tool_call_always_allow($server, cx).await; + } } }; }