mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
ACP Extensions (#40663)
Adds the ability to install ACP agents via extensions Release Notes: - N/A *or* Added/Fixed/Improved ...
This commit is contained in:
parent
7644e797fe
commit
8de4b360e8
17 changed files with 982 additions and 118 deletions
|
|
@ -6,8 +6,11 @@ use std::sync::Arc;
|
|||
use acp_thread::AcpThread;
|
||||
use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
|
||||
use db::kvp::{Dismissable, KEY_VALUE_STORE};
|
||||
use project::agent_server_store::{
|
||||
AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
|
||||
use project::{
|
||||
ExternalAgentServerName,
|
||||
agent_server_store::{
|
||||
AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{
|
||||
|
|
@ -41,6 +44,8 @@ use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
|
|||
use client::{UserStore, zed_urls};
|
||||
use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit};
|
||||
use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
|
||||
use extension::ExtensionEvents;
|
||||
use extension_host::ExtensionStore;
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter,
|
||||
|
|
@ -422,6 +427,7 @@ pub struct AgentPanel {
|
|||
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
agent_navigation_menu: Option<Entity<ContextMenu>>,
|
||||
_extension_subscription: Option<Subscription>,
|
||||
width: Option<Pixels>,
|
||||
height: Option<Pixels>,
|
||||
zoomed: bool,
|
||||
|
|
@ -632,7 +638,24 @@ impl AgentPanel {
|
|||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
// Subscribe to extension events to sync agent servers when extensions change
|
||||
let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
|
||||
{
|
||||
Some(
|
||||
cx.subscribe(&extension_events, |this, _source, event, cx| match event {
|
||||
extension::Event::ExtensionInstalled(_)
|
||||
| extension::Event::ExtensionUninstalled(_)
|
||||
| extension::Event::ExtensionsInstalledChanged => {
|
||||
this.sync_agent_servers_from_extensions(cx);
|
||||
}
|
||||
_ => {}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut panel = Self {
|
||||
active_view,
|
||||
workspace,
|
||||
user_store,
|
||||
|
|
@ -650,6 +673,7 @@ impl AgentPanel {
|
|||
agent_panel_menu_handle: PopoverMenuHandle::default(),
|
||||
agent_navigation_menu_handle: PopoverMenuHandle::default(),
|
||||
agent_navigation_menu: None,
|
||||
_extension_subscription: extension_subscription,
|
||||
width: None,
|
||||
height: None,
|
||||
zoomed: false,
|
||||
|
|
@ -659,7 +683,11 @@ impl AgentPanel {
|
|||
history_store,
|
||||
selected_agent: AgentType::default(),
|
||||
loading: false,
|
||||
}
|
||||
};
|
||||
|
||||
// Initial sync of agent servers from extensions
|
||||
panel.sync_agent_servers_from_extensions(cx);
|
||||
panel
|
||||
}
|
||||
|
||||
pub fn toggle_focus(
|
||||
|
|
@ -1309,6 +1337,31 @@ impl AgentPanel {
|
|||
self.selected_agent.clone()
|
||||
}
|
||||
|
||||
fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(extension_store) = ExtensionStore::try_global(cx) {
|
||||
let (manifests, extensions_dir) = {
|
||||
let store = extension_store.read(cx);
|
||||
let installed = store.installed_extensions();
|
||||
let manifests: Vec<_> = installed
|
||||
.iter()
|
||||
.map(|(id, entry)| (id.clone(), entry.manifest.clone()))
|
||||
.collect();
|
||||
let extensions_dir = paths::extensions_dir().join("installed");
|
||||
(manifests, extensions_dir)
|
||||
};
|
||||
|
||||
self.project.update(cx, |project, cx| {
|
||||
project.agent_server_store().update(cx, |store, cx| {
|
||||
let manifest_refs: Vec<_> = manifests
|
||||
.iter()
|
||||
.map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
|
||||
.collect();
|
||||
store.sync_extension_agents(manifest_refs, extensions_dir, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_agent_thread(
|
||||
&mut self,
|
||||
agent: AgentType,
|
||||
|
|
@ -1744,6 +1797,16 @@ impl AgentPanel {
|
|||
let agent_server_store = self.project.read(cx).agent_server_store().clone();
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
// Get custom icon path for selected agent before building menu (to avoid borrow issues)
|
||||
let selected_agent_custom_icon =
|
||||
if let AgentType::Custom { name, .. } = &self.selected_agent {
|
||||
agent_server_store
|
||||
.read(cx)
|
||||
.agent_icon(&ExternalAgentServerName(name.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::ExternalAgentThread { thread_view } => {
|
||||
thread_view.read(cx).as_native_thread(cx)
|
||||
|
|
@ -1757,12 +1820,7 @@ impl AgentPanel {
|
|||
{
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"New…",
|
||||
&ToggleNewThreadMenu,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
Tooltip::for_action_in("New…", &ToggleNewThreadMenu, &focus_handle, cx)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -1781,8 +1839,7 @@ impl AgentPanel {
|
|||
|
||||
let active_thread = active_thread.clone();
|
||||
Some(ContextMenu::build(window, cx, |menu, _window, cx| {
|
||||
menu
|
||||
.context(focus_handle.clone())
|
||||
menu.context(focus_handle.clone())
|
||||
.header("Zed Agent")
|
||||
.when_some(active_thread, |this, active_thread| {
|
||||
let thread = active_thread.read(cx);
|
||||
|
|
@ -1939,77 +1996,110 @@ impl AgentPanel {
|
|||
}),
|
||||
)
|
||||
.map(|mut menu| {
|
||||
let agent_names = agent_server_store
|
||||
.read(cx)
|
||||
let agent_server_store_read = agent_server_store.read(cx);
|
||||
let agent_names = agent_server_store_read
|
||||
.external_agents()
|
||||
.filter(|name| {
|
||||
name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME
|
||||
name.0 != GEMINI_NAME
|
||||
&& name.0 != CLAUDE_CODE_NAME
|
||||
&& name.0 != CODEX_NAME
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let custom_settings = cx.global::<SettingsStore>().get::<AllAgentServersSettings>(None).custom.clone();
|
||||
let custom_settings = cx
|
||||
.global::<SettingsStore>()
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.custom
|
||||
.clone();
|
||||
for agent_name in agent_names {
|
||||
menu = menu.item(
|
||||
ContextMenuEntry::new(format!("New {} Thread", agent_name))
|
||||
.icon(IconName::Terminal)
|
||||
.icon_color(Color::Muted)
|
||||
.disabled(is_via_collab)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
let agent_name = agent_name.clone();
|
||||
let custom_settings = custom_settings.clone();
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) =
|
||||
workspace.panel::<AgentPanel>(cx)
|
||||
{
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(
|
||||
AgentType::Custom {
|
||||
name: agent_name.clone().into(),
|
||||
command: custom_settings
|
||||
.get(&agent_name.0)
|
||||
.map(|settings| {
|
||||
settings.command.clone()
|
||||
})
|
||||
.unwrap_or(placeholder_command()),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let icon_path = agent_server_store_read.agent_icon(&agent_name);
|
||||
let mut entry =
|
||||
ContextMenuEntry::new(format!("New {} Thread", agent_name));
|
||||
if let Some(icon_path) = icon_path {
|
||||
entry = entry.custom_icon_path(icon_path);
|
||||
} else {
|
||||
entry = entry.icon(IconName::Terminal);
|
||||
}
|
||||
entry = entry
|
||||
.icon_color(Color::Muted)
|
||||
.disabled(is_via_collab)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
let agent_name = agent_name.clone();
|
||||
let custom_settings = custom_settings.clone();
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) =
|
||||
workspace.panel::<AgentPanel>(cx)
|
||||
{
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(
|
||||
AgentType::Custom {
|
||||
name: agent_name
|
||||
.clone()
|
||||
.into(),
|
||||
command: custom_settings
|
||||
.get(&agent_name.0)
|
||||
.map(|settings| {
|
||||
settings
|
||||
.command
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or(
|
||||
placeholder_command(
|
||||
),
|
||||
),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
menu = menu.item(entry);
|
||||
}
|
||||
|
||||
menu
|
||||
})
|
||||
.separator().link(
|
||||
"Add Other Agents",
|
||||
OpenBrowser {
|
||||
url: zed_urls::external_agents_docs(cx),
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
.separator()
|
||||
.link(
|
||||
"Add Other Agents",
|
||||
OpenBrowser {
|
||||
url: zed_urls::external_agents_docs(cx),
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
let selected_agent_label = self.selected_agent.label();
|
||||
|
||||
let has_custom_icon = selected_agent_custom_icon.is_some();
|
||||
let selected_agent = div()
|
||||
.id("selected_agent_icon")
|
||||
.when_some(self.selected_agent.icon(), |this, icon| {
|
||||
.when_some(selected_agent_custom_icon, |this, icon_path| {
|
||||
let label = selected_agent_label.clone();
|
||||
this.px(DynamicSpacing::Base02.rems(cx))
|
||||
.child(Icon::new(icon).color(Color::Muted))
|
||||
.child(Icon::from_path(icon_path).color(Color::Muted))
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx)
|
||||
Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
|
||||
})
|
||||
})
|
||||
.when(!has_custom_icon, |this| {
|
||||
this.when_some(self.selected_agent.icon(), |this, icon| {
|
||||
let label = selected_agent_label.clone();
|
||||
this.px(DynamicSpacing::Base02.rems(cx))
|
||||
.child(Icon::new(icon).color(Color::Muted))
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
|
||||
})
|
||||
})
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
h_flex()
|
||||
|
|
|
|||
|
|
@ -467,6 +467,7 @@ CREATE TABLE extension_versions (
|
|||
provides_grammars BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
provides_language_servers BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
provides_context_servers BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
provides_agent_servers BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
provides_slash_commands BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
provides_indexed_docs_providers BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
provides_snippets BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
alter table extension_versions
|
||||
add column provides_agent_servers bool not null default false
|
||||
|
|
@ -310,6 +310,9 @@ impl Database {
|
|||
.provides
|
||||
.contains(&ExtensionProvides::ContextServers),
|
||||
),
|
||||
provides_agent_servers: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::AgentServers),
|
||||
),
|
||||
provides_slash_commands: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::SlashCommands),
|
||||
),
|
||||
|
|
@ -422,6 +425,10 @@ fn apply_provides_filter(
|
|||
condition = condition.add(extension_version::Column::ProvidesContextServers.eq(true));
|
||||
}
|
||||
|
||||
if provides_filter.contains(&ExtensionProvides::AgentServers) {
|
||||
condition = condition.add(extension_version::Column::ProvidesAgentServers.eq(true));
|
||||
}
|
||||
|
||||
if provides_filter.contains(&ExtensionProvides::SlashCommands) {
|
||||
condition = condition.add(extension_version::Column::ProvidesSlashCommands.eq(true));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub struct Model {
|
|||
pub provides_grammars: bool,
|
||||
pub provides_language_servers: bool,
|
||||
pub provides_context_servers: bool,
|
||||
pub provides_agent_servers: bool,
|
||||
pub provides_slash_commands: bool,
|
||||
pub provides_indexed_docs_providers: bool,
|
||||
pub provides_snippets: bool,
|
||||
|
|
@ -57,6 +58,10 @@ impl Model {
|
|||
provides.insert(ExtensionProvides::ContextServers);
|
||||
}
|
||||
|
||||
if self.provides_agent_servers {
|
||||
provides.insert(ExtensionProvides::AgentServers);
|
||||
}
|
||||
|
||||
if self.provides_slash_commands {
|
||||
provides.insert(ExtensionProvides::SlashCommands);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,72 @@ test_both_dbs!(
|
|||
test_extensions_sqlite
|
||||
);
|
||||
|
||||
test_both_dbs!(
|
||||
test_agent_servers_filter,
|
||||
test_agent_servers_filter_postgres,
|
||||
test_agent_servers_filter_sqlite
|
||||
);
|
||||
|
||||
async fn test_agent_servers_filter(db: &Arc<Database>) {
|
||||
// No extensions initially
|
||||
let versions = db.get_known_extension_versions().await.unwrap();
|
||||
assert!(versions.is_empty());
|
||||
|
||||
// Shared timestamp
|
||||
let t0 = time::OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
|
||||
let t0 = time::PrimitiveDateTime::new(t0.date(), t0.time());
|
||||
|
||||
// Insert two extensions, only one provides AgentServers
|
||||
db.insert_extension_versions(
|
||||
&[
|
||||
(
|
||||
"ext_agent_servers",
|
||||
vec![NewExtensionVersion {
|
||||
name: "Agent Servers Provider".into(),
|
||||
version: semver::Version::parse("1.0.0").unwrap(),
|
||||
description: "has agent servers".into(),
|
||||
authors: vec!["author".into()],
|
||||
repository: "org/agent-servers".into(),
|
||||
schema_version: 1,
|
||||
wasm_api_version: None,
|
||||
provides: BTreeSet::from_iter([ExtensionProvides::AgentServers]),
|
||||
published_at: t0,
|
||||
}],
|
||||
),
|
||||
(
|
||||
"ext_plain",
|
||||
vec![NewExtensionVersion {
|
||||
name: "Plain Extension".into(),
|
||||
version: semver::Version::parse("0.1.0").unwrap(),
|
||||
description: "no agent servers".into(),
|
||||
authors: vec!["author2".into()],
|
||||
repository: "org/plain".into(),
|
||||
schema_version: 1,
|
||||
wasm_api_version: None,
|
||||
provides: BTreeSet::default(),
|
||||
published_at: t0,
|
||||
}],
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Filter by AgentServers provides
|
||||
let provides_filter = BTreeSet::from_iter([ExtensionProvides::AgentServers]);
|
||||
|
||||
let filtered = db
|
||||
.get_extensions(None, Some(&provides_filter), 1, 10)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Expect only the extension that declared AgentServers
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].id.as_ref(), "ext_agent_servers");
|
||||
}
|
||||
|
||||
async fn test_extensions(db: &Arc<Database>) {
|
||||
let versions = db.get_known_extension_versions().await.unwrap();
|
||||
assert!(versions.is_empty());
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ pub struct ExtensionManifest {
|
|||
#[serde(default)]
|
||||
pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>,
|
||||
#[serde(default)]
|
||||
pub agent_servers: BTreeMap<Arc<str>, AgentServerManifestEntry>,
|
||||
#[serde(default)]
|
||||
pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
|
||||
#[serde(default)]
|
||||
pub snippets: Option<PathBuf>,
|
||||
|
|
@ -138,6 +140,48 @@ pub struct LibManifestEntry {
|
|||
pub version: Option<SemanticVersion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct AgentServerManifestEntry {
|
||||
/// Display name for the agent (shown in menus).
|
||||
pub name: String,
|
||||
/// Environment variables to set when launching the agent server.
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
/// Optional icon path (relative to extension root, e.g., "ai.svg").
|
||||
/// Should be a small SVG icon for display in menus.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// Per-target configuration for archive-based installation.
|
||||
/// The key format is "{os}-{arch}" where:
|
||||
/// - os: "darwin" (macOS), "linux", "windows"
|
||||
/// - arch: "aarch64" (arm64), "x86_64"
|
||||
///
|
||||
/// Example:
|
||||
/// ```toml
|
||||
/// [agent_servers.myagent.targets.darwin-aarch64]
|
||||
/// archive = "https://example.com/myagent-darwin-arm64.zip"
|
||||
/// cmd = "./myagent"
|
||||
/// args = ["--serve"]
|
||||
/// sha256 = "abc123..." # optional
|
||||
/// ```
|
||||
pub targets: HashMap<String, TargetConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub struct TargetConfig {
|
||||
/// URL to download the archive from (e.g., "https://github.com/owner/repo/releases/download/v1.0.0/myagent-darwin-arm64.zip")
|
||||
pub archive: String,
|
||||
/// Command to run (e.g., "./myagent" or "./myagent.exe")
|
||||
pub cmd: String,
|
||||
/// Command-line arguments to pass to the agent server.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Optional SHA-256 hash of the archive for verification.
|
||||
/// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub.
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
pub enum ExtensionLibraryKind {
|
||||
Rust,
|
||||
|
|
@ -266,6 +310,7 @@ fn manifest_from_old_manifest(
|
|||
.collect(),
|
||||
language_servers: Default::default(),
|
||||
context_servers: BTreeMap::default(),
|
||||
agent_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
|
|
@ -298,6 +343,7 @@ mod tests {
|
|||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
context_servers: BTreeMap::default(),
|
||||
agent_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: vec![],
|
||||
|
|
@ -404,4 +450,31 @@ mod tests {
|
|||
);
|
||||
assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
|
||||
}
|
||||
#[test]
|
||||
fn parse_manifest_with_agent_server_archive_launcher() {
|
||||
let toml_src = r#"
|
||||
id = "example.agent-server-ext"
|
||||
name = "Agent Server Example"
|
||||
version = "1.0.0"
|
||||
schema_version = 0
|
||||
|
||||
[agent_servers.foo]
|
||||
name = "Foo Agent"
|
||||
|
||||
[agent_servers.foo.targets.linux-x86_64]
|
||||
archive = "https://example.com/agent-linux-x64.tar.gz"
|
||||
cmd = "./agent"
|
||||
args = ["--serve"]
|
||||
"#;
|
||||
|
||||
let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse");
|
||||
assert_eq!(manifest.id.as_ref(), "example.agent-server-ext");
|
||||
assert!(manifest.agent_servers.contains_key("foo"));
|
||||
let entry = manifest.agent_servers.get("foo").unwrap();
|
||||
assert!(entry.targets.contains_key("linux-x86_64"));
|
||||
let target = entry.targets.get("linux-x86_64").unwrap();
|
||||
assert_eq!(target.archive, "https://example.com/agent-linux-x64.tar.gz");
|
||||
assert_eq!(target.cmd, "./agent");
|
||||
assert_eq!(target.args, vec!["--serve"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,6 +235,21 @@ async fn copy_extension_resources(
|
|||
.with_context(|| "failed to copy icons")?;
|
||||
}
|
||||
|
||||
for (_, agent_entry) in &manifest.agent_servers {
|
||||
if let Some(icon_path) = &agent_entry.icon {
|
||||
let source_icon = extension_path.join(icon_path);
|
||||
let dest_icon = output_dir.join(icon_path);
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = dest_icon.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::copy(&source_icon, &dest_icon)
|
||||
.with_context(|| format!("failed to copy agent server icon '{}'", icon_path))?;
|
||||
}
|
||||
}
|
||||
|
||||
if !manifest.languages.is_empty() {
|
||||
let output_languages_dir = output_dir.join("languages");
|
||||
fs::create_dir_all(&output_languages_dir)?;
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ fn manifest() -> ExtensionManifest {
|
|||
.into_iter()
|
||||
.collect(),
|
||||
context_servers: BTreeMap::default(),
|
||||
agent_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: vec![ExtensionCapability::ProcessExec(
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ mod tests {
|
|||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
context_servers: BTreeMap::default(),
|
||||
agent_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: vec![],
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
|||
.collect(),
|
||||
language_servers: BTreeMap::default(),
|
||||
context_servers: BTreeMap::default(),
|
||||
agent_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
|
|
@ -189,6 +190,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
|||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
context_servers: BTreeMap::default(),
|
||||
agent_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
|
|
@ -368,6 +370,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
|||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
context_servers: BTreeMap::default(),
|
||||
agent_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ pub fn init(cx: &mut App) {
|
|||
ExtensionCategoryFilter::ContextServers => {
|
||||
ExtensionProvides::ContextServers
|
||||
}
|
||||
ExtensionCategoryFilter::AgentServers => ExtensionProvides::AgentServers,
|
||||
ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
|
||||
ExtensionCategoryFilter::IndexedDocsProviders => {
|
||||
ExtensionProvides::IndexedDocsProviders
|
||||
|
|
@ -189,6 +190,7 @@ fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
|
|||
ExtensionProvides::Grammars => "Grammars",
|
||||
ExtensionProvides::LanguageServers => "Language Servers",
|
||||
ExtensionProvides::ContextServers => "MCP Servers",
|
||||
ExtensionProvides::AgentServers => "Agent Servers",
|
||||
ExtensionProvides::SlashCommands => "Slash Commands",
|
||||
ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
|
||||
ExtensionProvides::Snippets => "Snippets",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::{
|
||||
any::Any,
|
||||
borrow::Borrow,
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr as _,
|
||||
sync::Arc,
|
||||
|
|
@ -126,13 +127,198 @@ enum AgentServerStoreState {
|
|||
pub struct AgentServerStore {
|
||||
state: AgentServerStoreState,
|
||||
external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
|
||||
agent_icons: HashMap<ExternalAgentServerName, SharedString>,
|
||||
}
|
||||
|
||||
pub struct AgentServersUpdated;
|
||||
|
||||
impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ext_agent_tests {
|
||||
use super::*;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
// Helper to build a store in Collab mode so we can mutate internal maps without
|
||||
// needing to spin up a full project environment.
|
||||
fn collab_store() -> AgentServerStore {
|
||||
AgentServerStore {
|
||||
state: AgentServerStoreState::Collab,
|
||||
external_agents: HashMap::default(),
|
||||
agent_icons: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// A simple fake that implements ExternalAgentServer without needing async plumbing.
|
||||
struct NoopExternalAgent;
|
||||
|
||||
impl ExternalAgentServer for NoopExternalAgent {
|
||||
fn get_command(
|
||||
&mut self,
|
||||
_root_dir: Option<&str>,
|
||||
_extra_env: HashMap<String, String>,
|
||||
_status_tx: Option<watch::Sender<SharedString>>,
|
||||
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
|
||||
_cx: &mut AsyncApp,
|
||||
) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
|
||||
Task::ready(Ok((
|
||||
AgentServerCommand {
|
||||
path: PathBuf::from("noop"),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
},
|
||||
"".to_string(),
|
||||
None,
|
||||
)))
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_agent_server_name_display() {
|
||||
let name = ExternalAgentServerName(SharedString::from("Ext: Tool"));
|
||||
let mut s = String::new();
|
||||
write!(&mut s, "{name}").unwrap();
|
||||
assert_eq!(s, "Ext: Tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_extension_agents_removes_previous_extension_entries() {
|
||||
let mut store = collab_store();
|
||||
|
||||
// Seed with a couple of agents that will be replaced by extensions
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("foo-agent")),
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("bar-agent")),
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("custom")),
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
);
|
||||
|
||||
// Simulate the removal phase: if we're syncing extensions that provide
|
||||
// "foo-agent" and "bar-agent", those should be removed first
|
||||
let extension_agent_names: HashSet<String> =
|
||||
["foo-agent".to_string(), "bar-agent".to_string()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let keys_to_remove: Vec<_> = store
|
||||
.external_agents
|
||||
.keys()
|
||||
.filter(|name| extension_agent_names.contains(name.0.as_ref()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for key in keys_to_remove {
|
||||
store.external_agents.remove(&key);
|
||||
}
|
||||
|
||||
// Only the custom entry should remain.
|
||||
let remaining: Vec<_> = store
|
||||
.external_agents
|
||||
.keys()
|
||||
.map(|k| k.0.to_string())
|
||||
.collect();
|
||||
assert_eq!(remaining, vec!["custom".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentServerStore {
|
||||
/// Synchronizes extension-provided agent servers with the store.
|
||||
pub fn sync_extension_agents<'a, I>(
|
||||
&mut self,
|
||||
manifests: I,
|
||||
extensions_dir: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) where
|
||||
I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
|
||||
{
|
||||
// Collect manifests first so we can iterate twice
|
||||
let manifests: Vec<_> = manifests.into_iter().collect();
|
||||
|
||||
// Remove existing extension-provided agents by tracking which ones we're about to add
|
||||
let extension_agent_names: HashSet<_> = manifests
|
||||
.iter()
|
||||
.flat_map(|(_, manifest)| manifest.agent_servers.keys().map(|k| k.to_string()))
|
||||
.collect();
|
||||
|
||||
let keys_to_remove: Vec<_> = self
|
||||
.external_agents
|
||||
.keys()
|
||||
.filter(|name| {
|
||||
// Remove if it matches an extension agent name from any extension
|
||||
extension_agent_names.contains(name.0.as_ref())
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in &keys_to_remove {
|
||||
self.external_agents.remove(key);
|
||||
self.agent_icons.remove(key);
|
||||
}
|
||||
|
||||
// Insert agent servers from extension manifests
|
||||
match &self.state {
|
||||
AgentServerStoreState::Local {
|
||||
project_environment,
|
||||
fs,
|
||||
http_client,
|
||||
..
|
||||
} => {
|
||||
for (ext_id, manifest) in manifests {
|
||||
for (agent_name, agent_entry) in &manifest.agent_servers {
|
||||
let display = SharedString::from(agent_entry.name.clone());
|
||||
|
||||
// Store absolute icon path if provided, resolving symlinks for dev extensions
|
||||
if let Some(icon) = &agent_entry.icon {
|
||||
let icon_path = extensions_dir.join(ext_id).join(icon);
|
||||
// Canonicalize to resolve symlinks (dev extensions are symlinked)
|
||||
let absolute_icon_path = icon_path
|
||||
.canonicalize()
|
||||
.unwrap_or(icon_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
self.agent_icons.insert(
|
||||
ExternalAgentServerName(display.clone()),
|
||||
SharedString::from(absolute_icon_path),
|
||||
);
|
||||
}
|
||||
|
||||
// Archive-based launcher (download from URL)
|
||||
self.external_agents.insert(
|
||||
ExternalAgentServerName(display),
|
||||
Box::new(LocalExtensionArchiveAgent {
|
||||
fs: fs.clone(),
|
||||
http_client: http_client.clone(),
|
||||
project_environment: project_environment.clone(),
|
||||
extension_id: Arc::from(ext_id),
|
||||
agent_id: agent_name.clone(),
|
||||
targets: agent_entry.targets.clone(),
|
||||
env: agent_entry.env.clone(),
|
||||
}) as Box<dyn ExternalAgentServer>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Only local projects support local extension agents
|
||||
}
|
||||
}
|
||||
|
||||
cx.emit(AgentServersUpdated);
|
||||
}
|
||||
|
||||
pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
|
||||
self.agent_icons.get(name).cloned()
|
||||
}
|
||||
|
||||
pub fn init_remote(session: &AnyProtoClient) {
|
||||
session.add_entity_message_handler(Self::handle_external_agents_updated);
|
||||
session.add_entity_message_handler(Self::handle_loading_status_updated);
|
||||
|
|
@ -202,7 +388,7 @@ impl AgentServerStore {
|
|||
.gemini
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.ignore_system_version)
|
||||
.unwrap_or(true),
|
||||
.unwrap_or(false),
|
||||
}),
|
||||
);
|
||||
self.external_agents.insert(
|
||||
|
|
@ -279,7 +465,9 @@ impl AgentServerStore {
|
|||
_subscriptions: [subscription],
|
||||
},
|
||||
external_agents: Default::default(),
|
||||
agent_icons: Default::default(),
|
||||
};
|
||||
if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
|
||||
this.agent_servers_settings_changed(cx);
|
||||
this
|
||||
}
|
||||
|
|
@ -288,7 +476,7 @@ impl AgentServerStore {
|
|||
// Set up the builtin agents here so they're immediately available in
|
||||
// remote projects--we know that the HeadlessProject on the other end
|
||||
// will have them.
|
||||
let external_agents = [
|
||||
let external_agents: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
|
||||
(
|
||||
CLAUDE_CODE_NAME.into(),
|
||||
Box::new(RemoteExternalAgentServer {
|
||||
|
|
@ -319,16 +507,15 @@ impl AgentServerStore {
|
|||
new_version_available_tx: None,
|
||||
}) as Box<dyn ExternalAgentServer>,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
];
|
||||
|
||||
Self {
|
||||
state: AgentServerStoreState::Remote {
|
||||
project_id,
|
||||
upstream_client,
|
||||
},
|
||||
external_agents,
|
||||
external_agents: external_agents.into_iter().collect(),
|
||||
agent_icons: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -336,6 +523,7 @@ impl AgentServerStore {
|
|||
Self {
|
||||
state: AgentServerStoreState::Collab,
|
||||
external_agents: Default::default(),
|
||||
agent_icons: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,7 +580,7 @@ impl AgentServerStore {
|
|||
envelope: TypedEnvelope<proto::GetAgentServerCommand>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::AgentServerCommand> {
|
||||
let (command, root_dir, login) = this
|
||||
let (command, root_dir, login_command) = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
let AgentServerStoreState::Local {
|
||||
downstream_client, ..
|
||||
|
|
@ -466,7 +654,7 @@ impl AgentServerStore {
|
|||
.map(|env| env.into_iter().collect())
|
||||
.unwrap_or_default(),
|
||||
root_dir: root_dir,
|
||||
login: login.map(|login| login.to_proto()),
|
||||
login: login_command.map(|cmd| cmd.to_proto()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -811,9 +999,7 @@ impl ExternalAgentServer for RemoteExternalAgentServer {
|
|||
env: Some(command.env),
|
||||
},
|
||||
root_dir,
|
||||
response
|
||||
.login
|
||||
.map(|login| task::SpawnInTerminal::from_proto(login)),
|
||||
None,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
|
@ -959,7 +1145,7 @@ impl ExternalAgentServer for LocalClaudeCode {
|
|||
.unwrap_or_default();
|
||||
env.insert("ANTHROPIC_API_KEY".into(), "".into());
|
||||
|
||||
let (mut command, login) = if let Some(mut custom_command) = custom_command {
|
||||
let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
|
||||
env.extend(custom_command.env.unwrap_or_default());
|
||||
custom_command.env = Some(env);
|
||||
(custom_command, None)
|
||||
|
|
@ -1000,7 +1186,11 @@ impl ExternalAgentServer for LocalClaudeCode {
|
|||
};
|
||||
|
||||
command.env.get_or_insert_default().extend(extra_env);
|
||||
Ok((command, root_dir.to_string_lossy().into_owned(), login))
|
||||
Ok((
|
||||
command,
|
||||
root_dir.to_string_lossy().into_owned(),
|
||||
login_command,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1080,10 +1270,15 @@ impl ExternalAgentServer for LocalCodex {
|
|||
.into_iter()
|
||||
.find(|asset| asset.name == asset_name)
|
||||
.with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
|
||||
// Strip "sha256:" prefix from digest if present (GitHub API format)
|
||||
let digest = asset
|
||||
.digest
|
||||
.as_deref()
|
||||
.and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
|
||||
::http_client::github_download::download_server_binary(
|
||||
&*http,
|
||||
&asset.browser_download_url,
|
||||
asset.digest.as_deref(),
|
||||
digest,
|
||||
&version_dir,
|
||||
if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
|
||||
AssetKind::Zip
|
||||
|
|
@ -1127,11 +1322,7 @@ impl ExternalAgentServer for LocalCodex {
|
|||
|
||||
pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
|
||||
|
||||
/// Assemble Codex release URL for the current OS/arch and the given version number.
|
||||
/// Returns None if the current target is unsupported.
|
||||
/// Example output:
|
||||
/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}
|
||||
fn asset_name(version: &str) -> Option<String> {
|
||||
fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
|
|
@ -1157,14 +1348,220 @@ fn asset_name(version: &str) -> Option<String> {
|
|||
"tar.gz"
|
||||
};
|
||||
|
||||
Some((arch, platform, ext))
|
||||
}
|
||||
|
||||
fn asset_name(version: &str) -> Option<String> {
|
||||
let (arch, platform, ext) = get_platform_info()?;
|
||||
Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
|
||||
}
|
||||
|
||||
struct LocalExtensionArchiveAgent {
|
||||
fs: Arc<dyn Fs>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
project_environment: Entity<ProjectEnvironment>,
|
||||
extension_id: Arc<str>,
|
||||
agent_id: Arc<str>,
|
||||
targets: HashMap<String, extension::TargetConfig>,
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
struct LocalCustomAgent {
|
||||
project_environment: Entity<ProjectEnvironment>,
|
||||
command: AgentServerCommand,
|
||||
}
|
||||
|
||||
impl ExternalAgentServer for LocalExtensionArchiveAgent {
|
||||
fn get_command(
|
||||
&mut self,
|
||||
root_dir: Option<&str>,
|
||||
extra_env: HashMap<String, String>,
|
||||
_status_tx: Option<watch::Sender<SharedString>>,
|
||||
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
|
||||
let fs = self.fs.clone();
|
||||
let http_client = self.http_client.clone();
|
||||
let project_environment = self.project_environment.downgrade();
|
||||
let extension_id = self.extension_id.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
let targets = self.targets.clone();
|
||||
let base_env = self.env.clone();
|
||||
|
||||
let root_dir: Arc<Path> = root_dir
|
||||
.map(|root_dir| Path::new(root_dir))
|
||||
.unwrap_or(paths::home_dir())
|
||||
.into();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
// Get project environment
|
||||
let mut env = project_environment
|
||||
.update(cx, |project_environment, cx| {
|
||||
project_environment.get_local_directory_environment(
|
||||
&Shell::System,
|
||||
root_dir.clone(),
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Merge manifest env and extra env
|
||||
env.extend(base_env);
|
||||
env.extend(extra_env);
|
||||
|
||||
let cache_key = format!("{}/{}", extension_id, agent_id);
|
||||
let dir = paths::data_dir().join("external_agents").join(&cache_key);
|
||||
fs.create_dir(&dir).await?;
|
||||
|
||||
// Determine platform key
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else {
|
||||
anyhow::bail!("unsupported OS");
|
||||
};
|
||||
|
||||
let arch = if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
} else if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else {
|
||||
anyhow::bail!("unsupported architecture");
|
||||
};
|
||||
|
||||
let platform_key = format!("{}-{}", os, arch);
|
||||
let target_config = targets.get(&platform_key).with_context(|| {
|
||||
format!(
|
||||
"no target specified for platform '{}'. Available platforms: {}",
|
||||
platform_key,
|
||||
targets
|
||||
.keys()
|
||||
.map(|k| k.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let archive_url = &target_config.archive;
|
||||
|
||||
// Use URL as version identifier for caching
|
||||
// Hash the URL to get a stable directory name
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
archive_url.hash(&mut hasher);
|
||||
let url_hash = hasher.finish();
|
||||
let version_dir = dir.join(format!("v_{:x}", url_hash));
|
||||
|
||||
if !fs.is_dir(&version_dir).await {
|
||||
// Determine SHA256 for verification
|
||||
let sha256 = if let Some(provided_sha) = &target_config.sha256 {
|
||||
// Use provided SHA256
|
||||
Some(provided_sha.clone())
|
||||
} else if archive_url.starts_with("https://github.com/") {
|
||||
// Try to fetch SHA256 from GitHub API
|
||||
// Parse URL to extract repo and tag/file info
|
||||
// Format: https://github.com/owner/repo/releases/download/tag/file.zip
|
||||
if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
|
||||
let parts: Vec<&str> = caps.split('/').collect();
|
||||
if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
|
||||
let repo = format!("{}/{}", parts[0], parts[1]);
|
||||
let tag = parts[4];
|
||||
let filename = parts[5..].join("/");
|
||||
|
||||
// Try to get release info from GitHub
|
||||
if let Ok(release) = ::http_client::github::get_release_by_tag_name(
|
||||
&repo,
|
||||
tag,
|
||||
http_client.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Find matching asset
|
||||
if let Some(asset) =
|
||||
release.assets.iter().find(|a| a.name == filename)
|
||||
{
|
||||
// Strip "sha256:" prefix if present
|
||||
asset.digest.as_ref().and_then(|d| {
|
||||
d.strip_prefix("sha256:")
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(d.clone()))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Determine archive type from URL
|
||||
let asset_kind = if archive_url.ends_with(".zip") {
|
||||
AssetKind::Zip
|
||||
} else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
|
||||
AssetKind::TarGz
|
||||
} else {
|
||||
anyhow::bail!("unsupported archive type in URL: {}", archive_url);
|
||||
};
|
||||
|
||||
// Download and extract
|
||||
::http_client::github_download::download_server_binary(
|
||||
&*http_client,
|
||||
archive_url,
|
||||
sha256.as_deref(),
|
||||
&version_dir,
|
||||
asset_kind,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Validate and resolve cmd path
|
||||
let cmd = &target_config.cmd;
|
||||
if cmd.contains("..") {
|
||||
anyhow::bail!("command path cannot contain '..': {}", cmd);
|
||||
}
|
||||
|
||||
let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") {
|
||||
// Relative to extraction directory
|
||||
version_dir.join(&cmd[2..])
|
||||
} else {
|
||||
// On PATH
|
||||
anyhow::bail!("command must be relative (start with './'): {}", cmd);
|
||||
};
|
||||
|
||||
anyhow::ensure!(
|
||||
fs.is_file(&cmd_path).await,
|
||||
"Missing command {} after extraction",
|
||||
cmd_path.to_string_lossy()
|
||||
);
|
||||
|
||||
let command = AgentServerCommand {
|
||||
path: cmd_path,
|
||||
args: target_config.args.clone(),
|
||||
env: Some(env),
|
||||
};
|
||||
|
||||
Ok((command, root_dir.to_string_lossy().into_owned(), None))
|
||||
})
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalAgentServer for LocalCustomAgent {
|
||||
fn get_command(
|
||||
&mut self,
|
||||
|
|
@ -1203,42 +1600,6 @@ impl ExternalAgentServer for LocalCustomAgent {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn assembles_codex_release_url_for_current_target() {
|
||||
let version_number = "0.1.0";
|
||||
|
||||
// This test fails the build if we are building a version of Zed
|
||||
// which does not have a known build of codex-acp, to prevent us
|
||||
// from accidentally doing a release on a new target without
|
||||
// realizing that codex-acp support will not work on that target!
|
||||
//
|
||||
// Additionally, it verifies that our logic for assembling URLs
|
||||
// correctly resolves to a known-good URL on each of our targets.
|
||||
let allowed = [
|
||||
"codex-acp-0.1.0-aarch64-apple-darwin.tar.gz",
|
||||
"codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz",
|
||||
"codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz",
|
||||
"codex-acp-0.1.0-x86_64-apple-darwin.tar.gz",
|
||||
"codex-acp-0.1.0-x86_64-pc-windows-msvc.zip",
|
||||
"codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
|
||||
];
|
||||
|
||||
if let Some(url) = super::asset_name(version_number) {
|
||||
assert!(
|
||||
allowed.contains(&url.as_str()),
|
||||
"Assembled asset name {} not in allowed list",
|
||||
url
|
||||
);
|
||||
} else {
|
||||
panic!(
|
||||
"This target does not have a known codex-acp release! We should fix this by building a release of codex-acp for this target, as otherwise codex-acp will not be usable with this Zed build."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const GEMINI_NAME: &'static str = "gemini";
|
||||
pub const CLAUDE_CODE_NAME: &'static str = "claude";
|
||||
pub const CODEX_NAME: &'static str = "codex";
|
||||
|
|
@ -1331,3 +1692,200 @@ impl settings::Settings for AllAgentServersSettings {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extension_agent_tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn extension_agent_constructs_proper_display_names() {
|
||||
// Verify the display name format for extension-provided agents
|
||||
let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
|
||||
assert!(name1.0.contains(": "));
|
||||
|
||||
let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
|
||||
assert_eq!(name2.0, "MyExt: MyAgent");
|
||||
|
||||
// Non-extension agents shouldn't have the separator
|
||||
let custom = ExternalAgentServerName(SharedString::from("custom"));
|
||||
assert!(!custom.0.contains(": "));
|
||||
}
|
||||
|
||||
struct NoopExternalAgent;
|
||||
|
||||
impl ExternalAgentServer for NoopExternalAgent {
|
||||
fn get_command(
|
||||
&mut self,
|
||||
_root_dir: Option<&str>,
|
||||
_extra_env: HashMap<String, String>,
|
||||
_status_tx: Option<watch::Sender<SharedString>>,
|
||||
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
|
||||
_cx: &mut AsyncApp,
|
||||
) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
|
||||
Task::ready(Ok((
|
||||
AgentServerCommand {
|
||||
path: PathBuf::from("noop"),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
},
|
||||
"".to_string(),
|
||||
None,
|
||||
)))
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_removes_only_extension_provided_agents() {
|
||||
let mut store = AgentServerStore {
|
||||
state: AgentServerStoreState::Collab,
|
||||
external_agents: HashMap::default(),
|
||||
agent_icons: HashMap::default(),
|
||||
};
|
||||
|
||||
// Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
);
|
||||
store.external_agents.insert(
|
||||
ExternalAgentServerName(SharedString::from("custom-agent")),
|
||||
Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
|
||||
);
|
||||
|
||||
// Simulate removal phase
|
||||
let keys_to_remove: Vec<_> = store
|
||||
.external_agents
|
||||
.keys()
|
||||
.filter(|name| name.0.contains(": "))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for key in keys_to_remove {
|
||||
store.external_agents.remove(&key);
|
||||
}
|
||||
|
||||
// Only custom-agent should remain
|
||||
assert_eq!(store.external_agents.len(), 1);
|
||||
assert!(
|
||||
store
|
||||
.external_agents
|
||||
.contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_launcher_constructs_with_all_fields() {
|
||||
use extension::AgentServerManifestEntry;
|
||||
|
||||
let mut env = HashMap::default();
|
||||
env.insert("GITHUB_TOKEN".into(), "secret".into());
|
||||
|
||||
let mut targets = HashMap::default();
|
||||
targets.insert(
|
||||
"darwin-aarch64".to_string(),
|
||||
extension::TargetConfig {
|
||||
archive:
|
||||
"https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
|
||||
.into(),
|
||||
cmd: "./agent".into(),
|
||||
args: vec![],
|
||||
sha256: None,
|
||||
},
|
||||
);
|
||||
|
||||
let _entry = AgentServerManifestEntry {
|
||||
name: "GitHub Agent".into(),
|
||||
targets,
|
||||
env,
|
||||
icon: None,
|
||||
};
|
||||
|
||||
// Verify display name construction
|
||||
let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
|
||||
assert_eq!(expected_name.0, "GitHub Agent");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
|
||||
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
||||
let http_client = http_client::FakeHttpClient::with_404_response();
|
||||
let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
|
||||
|
||||
let agent = LocalExtensionArchiveAgent {
|
||||
fs,
|
||||
http_client,
|
||||
project_environment,
|
||||
extension_id: Arc::from("my-extension"),
|
||||
agent_id: Arc::from("my-agent"),
|
||||
targets: {
|
||||
let mut map = HashMap::default();
|
||||
map.insert(
|
||||
"darwin-aarch64".to_string(),
|
||||
extension::TargetConfig {
|
||||
archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
|
||||
cmd: "./my-agent".into(),
|
||||
args: vec!["--serve".into()],
|
||||
sha256: None,
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
env: {
|
||||
let mut map = HashMap::default();
|
||||
map.insert("PORT".into(), "8080".into());
|
||||
map
|
||||
},
|
||||
};
|
||||
|
||||
// Verify agent is properly constructed
|
||||
assert_eq!(agent.extension_id.as_ref(), "my-extension");
|
||||
assert_eq!(agent.agent_id.as_ref(), "my-agent");
|
||||
assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
|
||||
assert!(agent.targets.contains_key("darwin-aarch64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_extension_agents_registers_archive_launcher() {
|
||||
use extension::AgentServerManifestEntry;
|
||||
|
||||
let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
|
||||
assert_eq!(expected_name.0, "Release Agent");
|
||||
|
||||
// Verify the manifest entry structure for archive-based installation
|
||||
let mut env = HashMap::default();
|
||||
env.insert("API_KEY".into(), "secret".into());
|
||||
|
||||
let mut targets = HashMap::default();
|
||||
targets.insert(
|
||||
"linux-x86_64".to_string(),
|
||||
extension::TargetConfig {
|
||||
archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
|
||||
cmd: "./release-agent".into(),
|
||||
args: vec!["serve".into()],
|
||||
sha256: None,
|
||||
},
|
||||
);
|
||||
|
||||
let manifest_entry = AgentServerManifestEntry {
|
||||
name: "Release Agent".into(),
|
||||
targets: targets.clone(),
|
||||
env,
|
||||
icon: None,
|
||||
};
|
||||
|
||||
// Verify target config is present
|
||||
assert!(manifest_entry.targets.contains_key("linux-x86_64"));
|
||||
let target = manifest_entry.targets.get("linux-x86_64").unwrap();
|
||||
assert_eq!(target.cmd, "./release-agent");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ use crate::{
|
|||
git_store::GitStore,
|
||||
lsp_store::{SymbolLocation, log_store::LogKind},
|
||||
};
|
||||
pub use agent_server_store::{AgentServerStore, AgentServersUpdated};
|
||||
pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName};
|
||||
pub use git_store::{
|
||||
ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
|
||||
git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub enum ExtensionProvides {
|
|||
Grammars,
|
||||
LanguageServers,
|
||||
ContextServers,
|
||||
AgentServers,
|
||||
SlashCommands,
|
||||
IndexedDocsProviders,
|
||||
Snippets,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub struct ContextMenuEntry {
|
|||
toggle: Option<(IconPosition, bool)>,
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
custom_icon_path: Option<SharedString>,
|
||||
icon_position: IconPosition,
|
||||
icon_size: IconSize,
|
||||
icon_color: Option<Color>,
|
||||
|
|
@ -66,6 +67,7 @@ impl ContextMenuEntry {
|
|||
toggle: None,
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
icon_position: IconPosition::Start,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
|
|
@ -90,6 +92,12 @@ impl ContextMenuEntry {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn custom_icon_path(mut self, path: impl Into<SharedString>) -> Self {
|
||||
self.custom_icon_path = Some(path.into());
|
||||
self.icon = None; // Clear IconName if custom path is set
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon_position(mut self, position: IconPosition) -> Self {
|
||||
self.icon_position = position;
|
||||
self
|
||||
|
|
@ -387,6 +395,7 @@ impl ContextMenu {
|
|||
label: label.into(),
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
|
|
@ -415,6 +424,7 @@ impl ContextMenu {
|
|||
label: label.into(),
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
|
|
@ -443,6 +453,7 @@ impl ContextMenu {
|
|||
label: label.into(),
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
|
|
@ -470,6 +481,7 @@ impl ContextMenu {
|
|||
label: label.into(),
|
||||
handler: Rc::new(move |_, window, cx| handler(window, cx)),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
icon_position: position,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
|
|
@ -528,6 +540,7 @@ impl ContextMenu {
|
|||
window.dispatch_action(action.boxed_clone(), cx);
|
||||
}),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
icon_position: IconPosition::End,
|
||||
icon_size: IconSize::Small,
|
||||
icon_color: None,
|
||||
|
|
@ -558,6 +571,7 @@ impl ContextMenu {
|
|||
window.dispatch_action(action.boxed_clone(), cx);
|
||||
}),
|
||||
icon: None,
|
||||
custom_icon_path: None,
|
||||
icon_size: IconSize::Small,
|
||||
icon_position: IconPosition::End,
|
||||
icon_color: None,
|
||||
|
|
@ -578,6 +592,7 @@ impl ContextMenu {
|
|||
action: Some(action.boxed_clone()),
|
||||
handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
|
||||
icon: Some(IconName::ArrowUpRight),
|
||||
custom_icon_path: None,
|
||||
icon_size: IconSize::XSmall,
|
||||
icon_position: IconPosition::End,
|
||||
icon_color: None,
|
||||
|
|
@ -897,6 +912,7 @@ impl ContextMenu {
|
|||
label,
|
||||
handler,
|
||||
icon,
|
||||
custom_icon_path,
|
||||
icon_position,
|
||||
icon_size,
|
||||
icon_color,
|
||||
|
|
@ -927,7 +943,29 @@ impl ContextMenu {
|
|||
Color::Default
|
||||
};
|
||||
|
||||
let label_element = if let Some(icon_name) = icon {
|
||||
let label_element = if let Some(custom_path) = custom_icon_path {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.when(
|
||||
*icon_position == IconPosition::Start && toggle.is_none(),
|
||||
|flex| {
|
||||
flex.child(
|
||||
Icon::from_path(custom_path.clone())
|
||||
.size(*icon_size)
|
||||
.color(icon_color),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(Label::new(label.clone()).color(label_color).truncate())
|
||||
.when(*icon_position == IconPosition::End, |flex| {
|
||||
flex.child(
|
||||
Icon::from_path(custom_path.clone())
|
||||
.size(*icon_size)
|
||||
.color(icon_color),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
} else if let Some(icon_name) = icon {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.when(
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ pub enum ExtensionCategoryFilter {
|
|||
Grammars,
|
||||
LanguageServers,
|
||||
ContextServers,
|
||||
AgentServers,
|
||||
SlashCommands,
|
||||
IndexedDocsProviders,
|
||||
Snippets,
|
||||
|
|
|
|||
Loading…
Reference in a new issue