дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
93712a1588
коміт
4f64ca3149
12 files changed
+226
-16
No files matched your search
@@ -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.
|
||||
|
||||
@@ -413,4 +413,6 @@ pub enum AgentEvent {
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
/// The installed or configured agent providers changed.
|
||||
Providers,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -257,6 +257,10 @@ function onEvent(e: AgentEvent) {
|
||||
scheduleChanges(e.task);
|
||||
break;
|
||||
}
|
||||
case "providers": {
|
||||
void refreshProviders();
|
||||
break;
|
||||
}
|
||||
case "entry": {
|
||||
if (
|
||||
!e.subagent &&
|
||||
|
||||
Reference in new issue
Заблокувати користувача