From 4f64ca3149c0c91a0c8c92060c20ef50dad5139f Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Mon, 2 Nov 2026 18:10:31 -0800 Subject: [PATCH] feat(agents): enable 1-click ACP registry install and updates for GitHub Copilot (#773) GitHub Copilot, Qwen Code and Mistral Vibe were listed in the ACP Registry under their registry ids (github-copilot-cli, qwen-code, mistral-vibe), so they showed as unrelated third-party agents and could not be installed over the built-in providers. Registry ids now map to the built-in ids: the rows use the built-in name and icon, install and update into /agents/.toml, show "x.y.z available" when the registry has a newer version, and fall back to the built-in command on PATH after Remove. Installing never overwrites a hand-written agent config, and Remove only deletes a registry install. Changing installed providers reloads them and drops stale probes, so New Task sees the installed version right away. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 17 ++++- crates/ide-agent/src/model.rs | 2 + crates/ide-agent/src/provider.rs | 18 +++-- crates/ide-agent/src/registry.rs | 80 +++++++++++++++++++++- crates/ide-agent/src/tests.rs | 75 ++++++++++++++++++++ crates/ide-host/src/ops/agent.rs | 12 +++- src/bindings.ts | 4 +- src/features/agents/AgentRegistryPage.tsx | 4 +- src/features/agents/HomeEditor.tsx | 7 +- src/features/agents/agentRegistry.test.tsx | 15 +++- src/features/agents/registry.ts | 4 ++ src/features/agents/store.ts | 4 ++ 12 files changed, 226 insertions(+), 16 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index f2fb12a57..4f61c8e0e 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -314,10 +314,25 @@ impl AgentManager { source: p.source.clone(), }) .collect(); - *self.shared.providers.write() = specs; + let mut prev = self.shared.providers.write(); + let mut probes = self.shared.probes.lock(); + probes.retain(|id, _| { + let old = prev.iter().find(|p| &p.id == id); + let new = specs.iter().find(|p| &p.id == id); + matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env) + }); + *prev = specs; list } + /// Re-reads configured/installed agents, drops cached probes, and notifies subscribers. + pub fn reload_providers(&self) { + let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); + *self.shared.providers.write() = specs; + self.shared.probes.lock().clear(); + self.shared.emit(AgentEvent::Providers); + } + /// The slash commands to complete for `provider`, in `task`'s directory or (without one) /// in the project: what the agent offered last time there, unadvertised builtins, /// and, for Claude Code, skills and custom commands on disk it has not listed. diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 67b2b6194..1ba50ae89 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -413,4 +413,6 @@ pub enum AgentEvent { index: u32, entry: AgentEntry, }, + /// The installed or configured agent providers changed. + Providers, } diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 47902e0ff..176087045 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -80,9 +80,16 @@ fn builtins() -> Vec { .collect() } -/// Whether `id` names a built-in agent. +/// Whether `id` names a built-in agent that needs no registry install (native drivers +/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and +/// updated from the ACP Registry into `/agents/.toml`. pub(crate) fn is_builtin(id: &str) -> bool { - BUILTIN.iter().any(|b| b.0 == id) + BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx"))) +} + +/// Display name of a built-in agent, when `id` is one. +pub(crate) fn builtin_name(id: &str) -> Option<&'static str> { + BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name) } /// An agent file, `/agents/.toml`. @@ -162,7 +169,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { Some(r) => AgentSource::Registry { version: r.version, update: None }, None => AgentSource::Custom, }; - let spec = ProviderSpec { + let mut spec = ProviderSpec { name: agent.name.unwrap_or_else(|| id.clone()), driver: agent.driver, command: agent.command, @@ -173,7 +180,10 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { id, }; match specs.iter_mut().find(|s| s.id == spec.id) { - Some(existing) => *existing = spec, + Some(existing) => { + spec.login_hint = existing.login_hint.clone(); + *existing = spec; + } None => specs.push(spec), } } diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs index 26bf31f3f..1c22007db 100644 --- a/crates/ide-agent/src/registry.rs +++ b/crates/ide-agent/src/registry.rs @@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult> { .into_iter() .filter_map(|v| serde_json::from_value::(v).ok()) .filter(|e| safe_segment(&e.id) && safe_segment(&e.version)) + .map(|mut e| { + e.id = canonical_id(&e.id).to_owned(); + if let Some(name) = provider::builtin_name(&e.id) { + e.name = name.to_owned(); + } + e + }) .collect()) } @@ -232,11 +239,16 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo None } -/// Registry agents that are built-in ones under another id. -const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"]; +/// Registry ids that map to a shorter built-in provider id (`/agents/.toml`). +const BUILTIN_ALIASES: &[(&str, &str)] = + &[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")]; + +fn canonical_id(id: &str) -> &str { + BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id) +} fn is_builtin(id: &str) -> bool { - provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id) + provider::is_builtin(canonical_id(id)) } fn on_path(command: &str) -> bool { @@ -387,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { /// Installs (or updates to) the registry's current version of `id`, streaming progress /// through `emit`, and points the agent file at it. pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { + let id = canonical_id(id); // The saved index is what the user picked from; fetch only when there is none. let text = match fs::read_to_string(&paths.cache_file) { Ok(text) => text, @@ -618,6 +631,7 @@ fn install_uv( /// Deletes a registry agent's file and installed files. pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> { + let id = canonical_id(id); if !safe_segment(id) { return Err(IdeError::invalid(format!("invalid agent id {id}"))); } @@ -946,4 +960,64 @@ mod tests { assert!(remove(&p, "amp-acp").is_err()); assert!(p.agent_file("amp-acp").exists()); } + + #[test] + fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() { + let dir = tempfile::tempdir().unwrap(); + let p = paths(dir.path()); + let index = r#"{"agents":[ + {"id":"github-copilot-cli","name":"Copilot","version":"1.0.105", + "description":"GitHub's AI pair programmer", + "distribution":{"npx":{"package":"@github/copilot@1.0.105","args":["--acp"]}}} + ]}"#; + let entries = parse(index).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot")); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!(shown.len(), 1); + assert_eq!( + (shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()), + ("copilot", "GitHub Copilot", None) + ); + + CustomAgent { + name: Some("GitHub Copilot".into()), + driver: Driver::Acp, + command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(), + args: vec!["--acp".into()], + env: BTreeMap::new(), + registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }), + } + .write(&p.agent_file("copilot")) + .unwrap(); + + let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node"); + assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true)); + + write_atomic(&p.cache_file, index.as_bytes()).unwrap(); + let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(spec.name, "GitHub Copilot"); + assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in."); + assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None }); + + let mut providers = vec![AgentProvider { + id: spec.id, + name: spec.name, + command: spec.command, + installed: true, + config: vec![], + commands: vec![], + source: spec.source, + }]; + mark_updates(&p, &mut providers); + assert_eq!( + providers[0].source, + AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) } + ); + + remove(&p, "github-copilot-cli").unwrap(); + let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap(); + assert_eq!(fallback.source, AgentSource::Builtin); + } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index b46251971..6bd3ea2ba 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -942,3 +942,78 @@ async fn task_project_root_controls_local_and_worktree_repositories() { } assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1); } + +#[tokio::test(flavor = "multi_thread")] +async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() { + let tmp = tempfile::tempdir().unwrap(); + let agents_dir = tmp.path().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" }); + std::fs::write(&fake_bin, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let agent = Arc::new(FakeAgent::default()); + let connector: Connector = { + let agent = agent.clone(); + Arc::new(move |_spec, _cwd, client, _stderr| { + let (a, b) = duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines); + agent.servers.lock().push(server); + Ok(Peer::connect_framed(ar, aw, client, Framing::Lines)) + }) + }; + let mgr = AgentManager::with_connector( + AgentPaths { + project_root: tmp.path().to_path_buf(), + store_dir: tmp.path().join("store"), + worktrees_dir: tmp.path().join("worktrees"), + models_file: tmp.path().join("models.json"), + custom_agents_dir: Some(agents_dir.clone()), + home_dir: None, + }, + tokio::runtime::Handle::current(), + connector, + ); + + // Seed a stale failed probe for copilot before installing its custom TOML. + mgr.shared.probes.lock().insert( + "copilot".into(), + AgentProbe { + provider: "copilot".into(), + name: None, + version: None, + protocol: "ACP".into(), + capabilities: AgentCapabilities::default(), + auth_methods: Vec::new(), + error: Some("copilot --acp was not found on PATH.".into()), + }, + ); + + let events = Arc::new(Mutex::new(Vec::new())); + let events_sink = events.clone(); + mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev))); + + let toml = format!( + "name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n", + fake_bin.to_string_lossy() + ); + std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap(); + + mgr.reload_providers(); + + assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers))); + assert!(!mgr.shared.probes.lock().contains_key("copilot")); + + let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap(); + assert!(copilot.installed); + assert!(copilot.command.contains(&*fake_bin.to_string_lossy())); + + let probe = mgr.probe("copilot", false).await.unwrap(); + assert!(probe.error.is_none()); +} diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index f72d603cb..5c16f2705 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -63,13 +63,21 @@ pub async fn registry_install( .await .map_err(join_err); host.end_request(request); - result? + result??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Removes a registry agent: its agent file and installed files. pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> { let paths = registry_paths(host); - tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)? + tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??; + for project in host.projects() { + project.agents.reload_providers(); + } + Ok(()) } /// Slash commands to complete for `provider` in `task`'s directory, or in the project diff --git a/src/bindings.ts b/src/bindings.ts index 22f176d2f..13367ac86 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1216,7 +1216,9 @@ export type AgentEvent = /** The task's conversation was cut to its first `length` entries (turns rolled back). */ { type: "truncated"; task: string; length: number } | /** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */ -{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry }; +{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } | +/** The installed or configured agent providers changed. */ +{ type: "providers" }; export type AgentImage = { mimeType: string, diff --git a/src/features/agents/AgentRegistryPage.tsx b/src/features/agents/AgentRegistryPage.tsx index a77a67ea7..e03417900 100644 --- a/src/features/agents/AgentRegistryPage.tsx +++ b/src/features/agents/AgentRegistryPage.tsx @@ -8,8 +8,9 @@ import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings"; import { isCancelled, nextRequestId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { openExternalUrl } from "@/platform/openUrl"; -import { Badge, Button, ProgressBar, SearchField, Spinner, T } from "@/ui"; +import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T } from "@/ui"; import { confirm } from "@/workbench/messages"; +import { providerIcon } from "./model"; import { DISTRIBUTION_LABEL, afterInstall, @@ -213,6 +214,7 @@ function AgentRow({
  • + {agent.name} diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index 3ff76b005..e12ef1e29 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -45,6 +45,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito // Agents installed in Settings (its own window) show up when this one is back in front. useEffect(() => { const refresh = () => void refreshProviders(); + refresh(); window.addEventListener("focus", refresh); return () => window.removeEventListener("focus", refresh); }, []); @@ -66,7 +67,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito }, [root, project?.workspace]); const provider = providers.find((p) => p.id === draft.provider) ?? providers[0]; - const probe = useProbe(provider?.installed ? provider.id : null); + const probe = useProbe(provider?.installed ? provider.id : null, provider?.command); const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS); const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon); // Without git under the project there are no worktrees: force the local checkout. @@ -224,7 +225,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito } /** What the agent reports about itself when it starts (no session is opened), once per agent. */ -function useProbe(provider: string | null): AgentProbe | null { +function useProbe(provider: string | null, command?: string): AgentProbe | null { const [probe, setProbe] = useState(null); useEffect(() => { if (!provider) return; @@ -235,6 +236,6 @@ function useProbe(provider: string | null): AgentProbe | null { return () => { live = false; }; - }, [provider]); + }, [provider, command]); return probe?.provider === provider ? probe : null; } diff --git a/src/features/agents/agentRegistry.test.tsx b/src/features/agents/agentRegistry.test.tsx index f1b88af84..f9c2bc8ed 100644 --- a/src/features/agents/agentRegistry.test.tsx +++ b/src/features/agents/agentRegistry.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import type { AgentProvider, AgentRegistry, InstallEvent, RegistryAgent, RequestId } from "@/bindings"; import { IpcError } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; +import { iconMarkup } from "@/ui/icons"; import { MessagesHost } from "@/workbench/messages"; import { AgentRegistryPage } from "./AgentRegistryPage"; import { ADD_AGENT, providerOptions } from "./providerOptions"; @@ -96,9 +97,10 @@ describe("registry helpers", () => { named("zed", "Zed"), named("cline", "Cline"), named("amp-acp", "Amp"), + named("copilot", "GitHub Copilot"), named("bob", "Bob", "2.0.9"), ]); - expect(sorted.map((a) => a.name)).toEqual(["Bob", "Amp", "Cline", "Agoragentic", "Zed"]); + expect(sorted.map((a) => a.name)).toEqual(["Bob", "GitHub Copilot", "Amp", "Cline", "Agoragentic", "Zed"]); }); it("drops a removed agent this system cannot install again", () => { @@ -141,6 +143,17 @@ describe("agent picker", () => { }); describe("Agents settings page", () => { + it("shows the built-in Copilot name and icon in its registry row", async () => { + const backend = new FakeBackend(); + backend.registry = { agents: [agent({ id: "copilot", name: "GitHub Copilot" })], warning: null }; + render(); + const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" }); + const expected = document.createElement("div"); + expected.innerHTML = iconMarkup("agent-copilot")!; + expect(copilot.querySelector("svg")?.innerHTML).toBe(expected.querySelector("svg")?.innerHTML); + expect(copilot.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + }); + it("lists registry agents, says they are third-party, and searches them", async () => { const backend = new FakeBackend(); render(); diff --git a/src/features/agents/registry.ts b/src/features/agents/registry.ts index eb41e7680..f71772ea8 100644 --- a/src/features/agents/registry.ts +++ b/src/features/agents/registry.ts @@ -63,6 +63,10 @@ export function afterRemove(agents: RegistryAgent[], id: string): RegistryAgent[ * no popularity data and lists alphabetically, which puts small or unrelated projects on top. */ const FEATURED = [ + "copilot", + "gemini", + "cursor", + "opencode", "amp-acp", "devin", "factory-droid", diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index c2810686f..b1b54f3e3 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -257,6 +257,10 @@ function onEvent(e: AgentEvent) { scheduleChanges(e.task); break; } + case "providers": { + void refreshProviders(); + break; + } case "entry": { if ( !e.subagent &&