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 <config>/agents/<id>.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 <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-03 10:10:31 +08:00
1 джерело 93712a1588
коміт 4f64ca3149
12 files changed
+226 -16

No files matched your search

+16 -1
Переглянути файл
@@ -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.
+2
Переглянути файл
@@ -413,4 +413,6 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// The installed or configured agent providers changed.
Providers,
}
+14 -4
Переглянути файл
@@ -80,9 +80,16 @@ fn builtins() -> Vec<ProviderSpec> {
.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 `<config>/agents/<id>.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, `<config>/agents/<id>.toml`.
@@ -162,7 +169,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
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<ProviderSpec> {
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),
}
}
+77 -3
Переглянути файл
@@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult<Vec<Entry>> {
.into_iter()
.filter_map(|v| serde_json::from_value::<Entry>(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 (`<config>/agents/<id>.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/[email protected]","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);
}
}
+75
Переглянути файл
@@ -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());
}
+10 -2
Переглянути файл
@@ -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
+3 -1
Переглянути файл
@@ -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,
+3 -1
Переглянути файл
@@ -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({
<li className={styles.row} aria-labelledby={titleId}>
<div className={styles.main}>
<div className={styles.titleLine}>
<Icon name={providerIcon(agent.id)} size={16} />
<span id={titleId} className={styles.name}>
{agent.name}
</span>
+4 -3
Переглянути файл
@@ -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<AgentProbe | null>(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;
}
+14 -1
Переглянути файл
@@ -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(<AgentRegistryPage backend={backend} />);
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(<AgentRegistryPage backend={backend} />);
+4
Переглянути файл
@@ -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",
+4
Переглянути файл
@@ -257,6 +257,10 @@ function onEvent(e: AgentEvent) {
scheduleChanges(e.task);
break;
}
case "providers": {
void refreshProviders();
break;
}
case "entry": {
if (
!e.subagent &&