diff --git a/crates/goose/src/providers/copilot_acp.rs b/crates/goose/src/providers/copilot_acp.rs new file mode 100644 index 00000000..662a2d65 --- /dev/null +++ b/crates/goose/src/providers/copilot_acp.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use futures::future::BoxFuture; +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::acp::{ + extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping, + ACP_CURRENT_MODEL, +}; +use crate::config::search_path::SearchPaths; +use crate::config::{Config, GooseMode}; +use crate::model::ModelConfig; +use crate::providers::base::{ProviderDef, ProviderMetadata}; + +const COPILOT_ACP_PROVIDER_NAME: &str = "copilot-acp"; +const COPILOT_ACP_DOC_URL: &str = "https://github.com/github/copilot-cli"; +const COPILOT_ACP_BINARY: &str = "copilot"; + +const MODE_AGENT: &str = "https://agentclientprotocol.com/protocol/session-modes#agent"; +const MODE_PLAN: &str = "https://agentclientprotocol.com/protocol/session-modes#plan"; + +pub struct CopilotAcpProvider; + +impl ProviderDef for CopilotAcpProvider { + type Provider = AcpProvider; + + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + COPILOT_ACP_PROVIDER_NAME, + "GitHub Copilot CLI (ACP)", + "Use goose with your GitHub Copilot subscription via the Copilot CLI.", + ACP_CURRENT_MODEL, + vec![], + COPILOT_ACP_DOC_URL, + vec![], + ) + .with_setup_steps(vec![ + "Install the Copilot CLI: `npm install -g @github/copilot`", + "Run `copilot login` to authenticate with your GitHub account", + "Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: copilot-acp\n GOOSE_MODEL: current", + "Restart goose for changes to take effect", + ]) + } + + fn from_env( + model: ModelConfig, + extensions: Vec, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + let config = Config::global(); + // with_npm() includes npm global bin dir (desktop app PATH may not) + let resolved_command = SearchPaths::builder() + .with_npm() + .resolve(COPILOT_ACP_BINARY)?; + let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); + + // Copilot uses standard ACP permission option IDs (allow_once, + // allow_always, reject_once) so kind-based fallback handles them. + let permission_mapping = PermissionMapping::default(); + + let mut args = vec!["--acp".to_string()]; + if model.model_name != ACP_CURRENT_MODEL { + args.push("--model".to_string()); + args.push(model.model_name.clone()); + } + + // Copilot modes are full protocol URIs. + // No approve-specific mode; permissions are handled separately. + let mode_mapping = HashMap::from([ + (GooseMode::Auto, MODE_AGENT.to_string()), + (GooseMode::Approve, MODE_AGENT.to_string()), + (GooseMode::SmartApprove, MODE_AGENT.to_string()), + (GooseMode::Chat, MODE_PLAN.to_string()), + ]); + + let provider_config = AcpProviderConfig { + command: resolved_command, + args, + env: vec![], + env_remove: vec![], + work_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + mcp_servers: extension_configs_to_mcp_servers(&extensions), + session_mode_id: Some(mode_mapping[&goose_mode].clone()), + mode_mapping, + permission_mapping, + notification_callback: None, + }; + + let metadata = Self::metadata(); + AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await + }) + } +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 8464ec4a..e7ff25ec 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -16,6 +16,7 @@ use super::{ claude_code::ClaudeCodeProvider, codex::CodexProvider, codex_acp::CodexAcpProvider, + copilot_acp::CopilotAcpProvider, cursor_agent::CursorAgentProvider, databricks::DatabricksProvider, gcpvertexai::GcpVertexAIProvider, @@ -59,6 +60,7 @@ async fn init_registry() -> RwLock { registry.register::(false); registry.register::(true); registry.register::(false); + registry.register::(false); registry.register::(true); registry.register::(false); registry.register::(true); diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index a2a02060..6934c4d6 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -14,6 +14,7 @@ pub mod claude_code; pub(crate) mod cli_common; pub mod codex; pub mod codex_acp; +pub mod copilot_acp; pub mod cursor_agent; pub mod databricks; pub mod embedding; diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index de7a1897..36b55d50 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -121,6 +121,7 @@ struct ProviderTestConfig { test_permissions: bool, test_smart_approve: bool, test_mode_update: bool, + test_mcp_tools: bool, test_context_length_exceeded: bool, expect_context_length_exceeded: bool, context_length_exceeded: usize, @@ -144,6 +145,7 @@ impl ProviderTestConfig { test_permissions: true, test_smart_approve: true, test_mode_update: true, + test_mcp_tools: true, test_context_length_exceeded: true, expect_context_length_exceeded: true, context_length_exceeded: 600_000, @@ -175,6 +177,11 @@ impl ProviderTestConfig { self } + fn test_mcp_tools(mut self, v: bool) -> Self { + self.test_mcp_tools = v; + self + } + fn expect_context_length_exceeded(mut self, v: bool) -> Self { self.expect_context_length_exceeded = v; self @@ -657,11 +664,13 @@ async fn test_provider(config: ProviderTestConfig) -> Result<()> { .await? .test_basic_response() .await?; - run_test(GooseMode::Auto).await?.test_tool_usage().await?; - run_test(GooseMode::Auto) - .await? - .test_image_content_support() - .await?; + if config.test_mcp_tools { + run_test(GooseMode::Auto).await?.test_tool_usage().await?; + run_test(GooseMode::Auto) + .await? + .test_image_content_support() + .await?; + } if config.model_switch_name.is_some() { run_test(GooseMode::Auto).await?.test_model_switch().await?; } @@ -892,6 +901,18 @@ async fn test_codex_acp_provider() -> Result<()> { .await } +// Requires: npm install -g @github/copilot +#[tokio::test] +async fn test_copilot_acp_provider() -> Result<()> { + ProviderTestConfig::with_agentic_provider("copilot-acp", ACP_CURRENT_MODEL, "copilot") + .model_switch_name("gpt-4.1") + // Copilot ignores mcpServers passed via session/new + // https://github.com/github/copilot-cli/issues/1040 + .test_mcp_tools(false) + .run() + .await +} + #[ctor::dtor] fn print_test_report() { TEST_REPORT.print_summary();