Update the `fs::RenameOptions` used by `project::lsp_store::LocalLspStore.deserialize_workspace_edit` in order to always set `create_parents` to `true`. Doing this ensures that we'll always create the folders for the new file path provided by the language server instead of failing to handle the request in case the parent - Introduce `create_parents` field to `fs::RenameOptions` - Update `fs::RealFs.rename` to ensure that the `create_parents` option is respected Closes #41820 Release Notes: - Fixed a bug where using language server's file renaming actions could fail if the parent directory of the new file did not exist
2265 lines
81 KiB
Rust
2265 lines
81 KiB
Rust
use std::{
|
|
any::Any,
|
|
borrow::Borrow,
|
|
path::{Path, PathBuf},
|
|
str::FromStr as _,
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use collections::HashMap;
|
|
use fs::{Fs, RemoveOptions, RenameOptions};
|
|
use futures::StreamExt as _;
|
|
use gpui::{
|
|
AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
|
|
};
|
|
use http_client::{HttpClient, github::AssetKind};
|
|
use node_runtime::NodeRuntime;
|
|
use remote::RemoteClient;
|
|
use rpc::{
|
|
AnyProtoClient, TypedEnvelope,
|
|
proto::{self, ExternalExtensionAgent},
|
|
};
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::{RegisterSetting, SettingsStore};
|
|
use task::{Shell, SpawnInTerminal};
|
|
use util::{ResultExt as _, debug_panic};
|
|
|
|
use crate::ProjectEnvironment;
|
|
|
|
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
|
|
pub struct AgentServerCommand {
|
|
#[serde(rename = "command")]
|
|
pub path: PathBuf,
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
pub env: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
impl std::fmt::Debug for AgentServerCommand {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let filtered_env = self.env.as_ref().map(|env| {
|
|
env.iter()
|
|
.map(|(k, v)| {
|
|
(
|
|
k,
|
|
if util::redact::should_redact(k) {
|
|
"[REDACTED]"
|
|
} else {
|
|
v
|
|
},
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
f.debug_struct("AgentServerCommand")
|
|
.field("path", &self.path)
|
|
.field("args", &self.args)
|
|
.field("env", &filtered_env)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
pub struct ExternalAgentServerName(pub SharedString);
|
|
|
|
impl std::fmt::Display for ExternalAgentServerName {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl From<&'static str> for ExternalAgentServerName {
|
|
fn from(value: &'static str) -> Self {
|
|
ExternalAgentServerName(value.into())
|
|
}
|
|
}
|
|
|
|
impl From<ExternalAgentServerName> for SharedString {
|
|
fn from(value: ExternalAgentServerName) -> Self {
|
|
value.0
|
|
}
|
|
}
|
|
|
|
impl Borrow<str> for ExternalAgentServerName {
|
|
fn borrow(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
pub trait ExternalAgentServer {
|
|
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>)>>;
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any;
|
|
}
|
|
|
|
impl dyn ExternalAgentServer {
|
|
fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
|
|
self.as_any_mut().downcast_mut()
|
|
}
|
|
}
|
|
|
|
enum AgentServerStoreState {
|
|
Local {
|
|
node_runtime: NodeRuntime,
|
|
fs: Arc<dyn Fs>,
|
|
project_environment: Entity<ProjectEnvironment>,
|
|
downstream_client: Option<(u64, AnyProtoClient)>,
|
|
settings: Option<AllAgentServersSettings>,
|
|
http_client: Arc<dyn HttpClient>,
|
|
extension_agents: Vec<(
|
|
Arc<str>,
|
|
String,
|
|
HashMap<String, extension::TargetConfig>,
|
|
HashMap<String, String>,
|
|
Option<String>,
|
|
)>,
|
|
_subscriptions: [Subscription; 1],
|
|
},
|
|
Remote {
|
|
project_id: u64,
|
|
upstream_client: Entity<RemoteClient>,
|
|
},
|
|
Collab,
|
|
}
|
|
|
|
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::{collections::HashSet, 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 all extension-provided agents
|
|
// (They will be re-added below if they're in the currently installed extensions)
|
|
self.external_agents.retain(|name, agent| {
|
|
if agent.downcast_mut::<LocalExtensionArchiveAgent>().is_some() {
|
|
self.agent_icons.remove(name);
|
|
false
|
|
} else {
|
|
// Keep the hardcoded external agents that don't come from extensions
|
|
// (In the future we may move these over to being extensions too.)
|
|
true
|
|
}
|
|
});
|
|
|
|
// Insert agent servers from extension manifests
|
|
match &mut self.state {
|
|
AgentServerStoreState::Local {
|
|
extension_agents, ..
|
|
} => {
|
|
extension_agents.clear();
|
|
for (ext_id, manifest) in manifests {
|
|
for (agent_name, agent_entry) in &manifest.agent_servers {
|
|
// Store absolute icon path if provided, resolving symlinks for dev extensions
|
|
let icon_path = 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(agent_name.clone().into()),
|
|
SharedString::from(absolute_icon_path.clone()),
|
|
);
|
|
Some(absolute_icon_path)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
extension_agents.push((
|
|
agent_name.clone(),
|
|
ext_id.to_owned(),
|
|
agent_entry.targets.clone(),
|
|
agent_entry.env.clone(),
|
|
icon_path,
|
|
));
|
|
}
|
|
}
|
|
self.reregister_agents(cx);
|
|
}
|
|
AgentServerStoreState::Remote {
|
|
project_id,
|
|
upstream_client,
|
|
} => {
|
|
let mut agents = vec![];
|
|
for (ext_id, manifest) in manifests {
|
|
for (agent_name, agent_entry) in &manifest.agent_servers {
|
|
// Store absolute icon path if provided, resolving symlinks for dev extensions
|
|
let icon = 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();
|
|
|
|
// Store icon locally for remote client
|
|
self.agent_icons.insert(
|
|
ExternalAgentServerName(agent_name.clone().into()),
|
|
SharedString::from(absolute_icon_path.clone()),
|
|
);
|
|
|
|
Some(absolute_icon_path)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
agents.push(ExternalExtensionAgent {
|
|
name: agent_name.to_string(),
|
|
icon_path: icon,
|
|
extension_id: ext_id.to_string(),
|
|
targets: agent_entry
|
|
.targets
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.to_proto()))
|
|
.collect(),
|
|
env: agent_entry
|
|
.env
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect(),
|
|
});
|
|
}
|
|
}
|
|
upstream_client
|
|
.read(cx)
|
|
.proto_client()
|
|
.send(proto::ExternalExtensionAgentsUpdated {
|
|
project_id: *project_id,
|
|
agents,
|
|
})
|
|
.log_err();
|
|
}
|
|
AgentServerStoreState::Collab => {
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
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);
|
|
session.add_entity_message_handler(Self::handle_new_version_available);
|
|
}
|
|
|
|
pub fn init_headless(session: &AnyProtoClient) {
|
|
session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
|
|
session.add_entity_request_handler(Self::handle_get_agent_server_command);
|
|
}
|
|
|
|
fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
|
|
let AgentServerStoreState::Local {
|
|
settings: old_settings,
|
|
..
|
|
} = &mut self.state
|
|
else {
|
|
debug_panic!(
|
|
"should not be subscribed to agent server settings changes in non-local project"
|
|
);
|
|
return;
|
|
};
|
|
|
|
let new_settings = cx
|
|
.global::<SettingsStore>()
|
|
.get::<AllAgentServersSettings>(None)
|
|
.clone();
|
|
if Some(&new_settings) == old_settings.as_ref() {
|
|
return;
|
|
}
|
|
|
|
self.reregister_agents(cx);
|
|
}
|
|
|
|
fn reregister_agents(&mut self, cx: &mut Context<Self>) {
|
|
let AgentServerStoreState::Local {
|
|
node_runtime,
|
|
fs,
|
|
project_environment,
|
|
downstream_client,
|
|
settings: old_settings,
|
|
http_client,
|
|
extension_agents,
|
|
..
|
|
} = &mut self.state
|
|
else {
|
|
debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
|
|
|
|
return;
|
|
};
|
|
|
|
let new_settings = cx
|
|
.global::<SettingsStore>()
|
|
.get::<AllAgentServersSettings>(None)
|
|
.clone();
|
|
|
|
self.external_agents.clear();
|
|
self.external_agents.insert(
|
|
GEMINI_NAME.into(),
|
|
Box::new(LocalGemini {
|
|
fs: fs.clone(),
|
|
node_runtime: node_runtime.clone(),
|
|
project_environment: project_environment.clone(),
|
|
custom_command: new_settings
|
|
.gemini
|
|
.clone()
|
|
.and_then(|settings| settings.custom_command()),
|
|
ignore_system_version: new_settings
|
|
.gemini
|
|
.as_ref()
|
|
.and_then(|settings| settings.ignore_system_version)
|
|
.unwrap_or(false),
|
|
}),
|
|
);
|
|
self.external_agents.insert(
|
|
CODEX_NAME.into(),
|
|
Box::new(LocalCodex {
|
|
fs: fs.clone(),
|
|
project_environment: project_environment.clone(),
|
|
custom_command: new_settings
|
|
.codex
|
|
.clone()
|
|
.and_then(|settings| settings.custom_command()),
|
|
http_client: http_client.clone(),
|
|
is_remote: downstream_client.is_some(),
|
|
}),
|
|
);
|
|
self.external_agents.insert(
|
|
CLAUDE_CODE_NAME.into(),
|
|
Box::new(LocalClaudeCode {
|
|
fs: fs.clone(),
|
|
node_runtime: node_runtime.clone(),
|
|
project_environment: project_environment.clone(),
|
|
custom_command: new_settings
|
|
.claude
|
|
.clone()
|
|
.and_then(|settings| settings.custom_command()),
|
|
}),
|
|
);
|
|
self.external_agents
|
|
.extend(
|
|
new_settings
|
|
.custom
|
|
.iter()
|
|
.filter_map(|(name, settings)| match settings {
|
|
CustomAgentServerSettings::Custom { command, .. } => Some((
|
|
ExternalAgentServerName(name.clone()),
|
|
Box::new(LocalCustomAgent {
|
|
command: command.clone(),
|
|
project_environment: project_environment.clone(),
|
|
}) as Box<dyn ExternalAgentServer>,
|
|
)),
|
|
CustomAgentServerSettings::Extension { .. } => None,
|
|
}),
|
|
);
|
|
self.external_agents.extend(extension_agents.iter().map(
|
|
|(agent_name, ext_id, targets, env, icon_path)| {
|
|
let name = ExternalAgentServerName(agent_name.clone().into());
|
|
|
|
// Restore icon if present
|
|
if let Some(icon) = icon_path {
|
|
self.agent_icons
|
|
.insert(name.clone(), SharedString::from(icon.clone()));
|
|
}
|
|
|
|
(
|
|
name,
|
|
Box::new(LocalExtensionArchiveAgent {
|
|
fs: fs.clone(),
|
|
http_client: http_client.clone(),
|
|
node_runtime: node_runtime.clone(),
|
|
project_environment: project_environment.clone(),
|
|
extension_id: Arc::from(&**ext_id),
|
|
targets: targets.clone(),
|
|
env: env.clone(),
|
|
agent_id: agent_name.clone(),
|
|
}) as Box<dyn ExternalAgentServer>,
|
|
)
|
|
},
|
|
));
|
|
|
|
*old_settings = Some(new_settings.clone());
|
|
|
|
if let Some((project_id, downstream_client)) = downstream_client {
|
|
downstream_client
|
|
.send(proto::ExternalAgentsUpdated {
|
|
project_id: *project_id,
|
|
names: self
|
|
.external_agents
|
|
.keys()
|
|
.map(|name| name.to_string())
|
|
.collect(),
|
|
})
|
|
.log_err();
|
|
}
|
|
cx.emit(AgentServersUpdated);
|
|
}
|
|
|
|
pub fn node_runtime(&self) -> Option<NodeRuntime> {
|
|
match &self.state {
|
|
AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn local(
|
|
node_runtime: NodeRuntime,
|
|
fs: Arc<dyn Fs>,
|
|
project_environment: Entity<ProjectEnvironment>,
|
|
http_client: Arc<dyn HttpClient>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
|
|
this.agent_servers_settings_changed(cx);
|
|
});
|
|
let mut this = Self {
|
|
state: AgentServerStoreState::Local {
|
|
node_runtime,
|
|
fs,
|
|
project_environment,
|
|
http_client,
|
|
downstream_client: None,
|
|
settings: None,
|
|
extension_agents: vec![],
|
|
_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
|
|
}
|
|
|
|
pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
|
|
// 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: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
|
|
(
|
|
CLAUDE_CODE_NAME.into(),
|
|
Box::new(RemoteExternalAgentServer {
|
|
project_id,
|
|
upstream_client: upstream_client.clone(),
|
|
name: CLAUDE_CODE_NAME.into(),
|
|
status_tx: None,
|
|
new_version_available_tx: None,
|
|
}) as Box<dyn ExternalAgentServer>,
|
|
),
|
|
(
|
|
CODEX_NAME.into(),
|
|
Box::new(RemoteExternalAgentServer {
|
|
project_id,
|
|
upstream_client: upstream_client.clone(),
|
|
name: CODEX_NAME.into(),
|
|
status_tx: None,
|
|
new_version_available_tx: None,
|
|
}) as Box<dyn ExternalAgentServer>,
|
|
),
|
|
(
|
|
GEMINI_NAME.into(),
|
|
Box::new(RemoteExternalAgentServer {
|
|
project_id,
|
|
upstream_client: upstream_client.clone(),
|
|
name: GEMINI_NAME.into(),
|
|
status_tx: None,
|
|
new_version_available_tx: None,
|
|
}) as Box<dyn ExternalAgentServer>,
|
|
),
|
|
];
|
|
|
|
Self {
|
|
state: AgentServerStoreState::Remote {
|
|
project_id,
|
|
upstream_client,
|
|
},
|
|
external_agents: external_agents.into_iter().collect(),
|
|
agent_icons: HashMap::default(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
|
|
Self {
|
|
state: AgentServerStoreState::Collab,
|
|
external_agents: Default::default(),
|
|
agent_icons: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
|
|
match &mut self.state {
|
|
AgentServerStoreState::Local {
|
|
downstream_client, ..
|
|
} => {
|
|
*downstream_client = Some((project_id, client.clone()));
|
|
// Send the current list of external agents downstream, but only after a delay,
|
|
// to avoid having the message arrive before the downstream project's agent server store
|
|
// sets up its handlers.
|
|
cx.spawn(async move |this, cx| {
|
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
let names = this.update(cx, |this, _| {
|
|
this.external_agents
|
|
.keys()
|
|
.map(|name| name.to_string())
|
|
.collect()
|
|
})?;
|
|
client
|
|
.send(proto::ExternalAgentsUpdated { project_id, names })
|
|
.log_err();
|
|
anyhow::Ok(())
|
|
})
|
|
.detach();
|
|
}
|
|
AgentServerStoreState::Remote { .. } => {
|
|
debug_panic!(
|
|
"external agents over collab not implemented, remote project should not be shared"
|
|
);
|
|
}
|
|
AgentServerStoreState::Collab => {
|
|
debug_panic!("external agents over collab not implemented, should not be shared");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn get_external_agent(
|
|
&mut self,
|
|
name: &ExternalAgentServerName,
|
|
) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
|
|
self.external_agents
|
|
.get_mut(name)
|
|
.map(|agent| agent.as_mut())
|
|
}
|
|
|
|
pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
|
|
self.external_agents.keys()
|
|
}
|
|
|
|
async fn handle_get_agent_server_command(
|
|
this: Entity<Self>,
|
|
envelope: TypedEnvelope<proto::GetAgentServerCommand>,
|
|
mut cx: AsyncApp,
|
|
) -> Result<proto::AgentServerCommand> {
|
|
let (command, root_dir, login_command) = this
|
|
.update(&mut cx, |this, cx| {
|
|
let AgentServerStoreState::Local {
|
|
downstream_client, ..
|
|
} = &this.state
|
|
else {
|
|
debug_panic!("should not receive GetAgentServerCommand in a non-local project");
|
|
bail!("unexpected GetAgentServerCommand request in a non-local project");
|
|
};
|
|
let agent = this
|
|
.external_agents
|
|
.get_mut(&*envelope.payload.name)
|
|
.with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
|
|
let (status_tx, new_version_available_tx) = downstream_client
|
|
.clone()
|
|
.map(|(project_id, downstream_client)| {
|
|
let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
|
|
let (new_version_available_tx, mut new_version_available_rx) =
|
|
watch::channel(None);
|
|
cx.spawn({
|
|
let downstream_client = downstream_client.clone();
|
|
let name = envelope.payload.name.clone();
|
|
async move |_, _| {
|
|
while let Some(status) = status_rx.recv().await.ok() {
|
|
downstream_client.send(
|
|
proto::ExternalAgentLoadingStatusUpdated {
|
|
project_id,
|
|
name: name.clone(),
|
|
status: status.to_string(),
|
|
},
|
|
)?;
|
|
}
|
|
anyhow::Ok(())
|
|
}
|
|
})
|
|
.detach_and_log_err(cx);
|
|
cx.spawn({
|
|
let name = envelope.payload.name.clone();
|
|
async move |_, _| {
|
|
if let Some(version) =
|
|
new_version_available_rx.recv().await.ok().flatten()
|
|
{
|
|
downstream_client.send(
|
|
proto::NewExternalAgentVersionAvailable {
|
|
project_id,
|
|
name: name.clone(),
|
|
version,
|
|
},
|
|
)?;
|
|
}
|
|
anyhow::Ok(())
|
|
}
|
|
})
|
|
.detach_and_log_err(cx);
|
|
(status_tx, new_version_available_tx)
|
|
})
|
|
.unzip();
|
|
anyhow::Ok(agent.get_command(
|
|
envelope.payload.root_dir.as_deref(),
|
|
HashMap::default(),
|
|
status_tx,
|
|
new_version_available_tx,
|
|
&mut cx.to_async(),
|
|
))
|
|
})??
|
|
.await?;
|
|
Ok(proto::AgentServerCommand {
|
|
path: command.path.to_string_lossy().into_owned(),
|
|
args: command.args,
|
|
env: command
|
|
.env
|
|
.map(|env| env.into_iter().collect())
|
|
.unwrap_or_default(),
|
|
root_dir: root_dir,
|
|
login: login_command.map(|cmd| cmd.to_proto()),
|
|
})
|
|
}
|
|
|
|
async fn handle_external_agents_updated(
|
|
this: Entity<Self>,
|
|
envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
|
|
mut cx: AsyncApp,
|
|
) -> Result<()> {
|
|
this.update(&mut cx, |this, cx| {
|
|
let AgentServerStoreState::Remote {
|
|
project_id,
|
|
upstream_client,
|
|
} = &this.state
|
|
else {
|
|
debug_panic!(
|
|
"handle_external_agents_updated should not be called for a non-remote project"
|
|
);
|
|
bail!("unexpected ExternalAgentsUpdated message")
|
|
};
|
|
|
|
let mut status_txs = this
|
|
.external_agents
|
|
.iter_mut()
|
|
.filter_map(|(name, agent)| {
|
|
Some((
|
|
name.clone(),
|
|
agent
|
|
.downcast_mut::<RemoteExternalAgentServer>()?
|
|
.status_tx
|
|
.take(),
|
|
))
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
let mut new_version_available_txs = this
|
|
.external_agents
|
|
.iter_mut()
|
|
.filter_map(|(name, agent)| {
|
|
Some((
|
|
name.clone(),
|
|
agent
|
|
.downcast_mut::<RemoteExternalAgentServer>()?
|
|
.new_version_available_tx
|
|
.take(),
|
|
))
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
this.external_agents = envelope
|
|
.payload
|
|
.names
|
|
.into_iter()
|
|
.map(|name| {
|
|
let agent = RemoteExternalAgentServer {
|
|
project_id: *project_id,
|
|
upstream_client: upstream_client.clone(),
|
|
name: ExternalAgentServerName(name.clone().into()),
|
|
status_tx: status_txs.remove(&*name).flatten(),
|
|
new_version_available_tx: new_version_available_txs
|
|
.remove(&*name)
|
|
.flatten(),
|
|
};
|
|
(
|
|
ExternalAgentServerName(name.into()),
|
|
Box::new(agent) as Box<dyn ExternalAgentServer>,
|
|
)
|
|
})
|
|
.collect();
|
|
cx.emit(AgentServersUpdated);
|
|
Ok(())
|
|
})?
|
|
}
|
|
|
|
async fn handle_external_extension_agents_updated(
|
|
this: Entity<Self>,
|
|
envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
|
|
mut cx: AsyncApp,
|
|
) -> Result<()> {
|
|
this.update(&mut cx, |this, cx| {
|
|
let AgentServerStoreState::Local {
|
|
extension_agents, ..
|
|
} = &mut this.state
|
|
else {
|
|
panic!(
|
|
"handle_external_extension_agents_updated \
|
|
should not be called for a non-remote project"
|
|
);
|
|
};
|
|
|
|
for ExternalExtensionAgent {
|
|
name,
|
|
icon_path,
|
|
extension_id,
|
|
targets,
|
|
env,
|
|
} in envelope.payload.agents
|
|
{
|
|
let icon_path_string = icon_path.clone();
|
|
if let Some(icon_path) = icon_path {
|
|
this.agent_icons.insert(
|
|
ExternalAgentServerName(name.clone().into()),
|
|
icon_path.into(),
|
|
);
|
|
}
|
|
extension_agents.push((
|
|
Arc::from(&*name),
|
|
extension_id,
|
|
targets
|
|
.into_iter()
|
|
.map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
|
|
.collect(),
|
|
env.into_iter().collect(),
|
|
icon_path_string,
|
|
));
|
|
}
|
|
|
|
this.reregister_agents(cx);
|
|
cx.emit(AgentServersUpdated);
|
|
Ok(())
|
|
})?
|
|
}
|
|
|
|
async fn handle_loading_status_updated(
|
|
this: Entity<Self>,
|
|
envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
|
|
mut cx: AsyncApp,
|
|
) -> Result<()> {
|
|
this.update(&mut cx, |this, _| {
|
|
if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
|
|
&& let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
|
|
&& let Some(status_tx) = &mut agent.status_tx
|
|
{
|
|
status_tx.send(envelope.payload.status.into()).ok();
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn handle_new_version_available(
|
|
this: Entity<Self>,
|
|
envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
|
|
mut cx: AsyncApp,
|
|
) -> Result<()> {
|
|
this.update(&mut cx, |this, _| {
|
|
if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
|
|
&& let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
|
|
&& let Some(new_version_available_tx) = &mut agent.new_version_available_tx
|
|
{
|
|
new_version_available_tx
|
|
.send(Some(envelope.payload.version))
|
|
.ok();
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn get_extension_id_for_agent(
|
|
&mut self,
|
|
name: &ExternalAgentServerName,
|
|
) -> Option<Arc<str>> {
|
|
self.external_agents.get_mut(name).and_then(|agent| {
|
|
agent
|
|
.as_any_mut()
|
|
.downcast_ref::<LocalExtensionArchiveAgent>()
|
|
.map(|ext_agent| ext_agent.extension_id.clone())
|
|
})
|
|
}
|
|
}
|
|
|
|
fn get_or_npm_install_builtin_agent(
|
|
binary_name: SharedString,
|
|
package_name: SharedString,
|
|
entrypoint_path: PathBuf,
|
|
minimum_version: Option<semver::Version>,
|
|
status_tx: Option<watch::Sender<SharedString>>,
|
|
new_version_available: Option<watch::Sender<Option<String>>>,
|
|
fs: Arc<dyn Fs>,
|
|
node_runtime: NodeRuntime,
|
|
cx: &mut AsyncApp,
|
|
) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
|
|
cx.spawn(async move |cx| {
|
|
let node_path = node_runtime.binary_path().await?;
|
|
let dir = paths::external_agents_dir().join(binary_name.as_str());
|
|
fs.create_dir(&dir).await?;
|
|
|
|
let mut stream = fs.read_dir(&dir).await?;
|
|
let mut versions = Vec::new();
|
|
let mut to_delete = Vec::new();
|
|
while let Some(entry) = stream.next().await {
|
|
let Ok(entry) = entry else { continue };
|
|
let Some(file_name) = entry.file_name() else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(name) = file_name.to_str()
|
|
&& let Some(version) = semver::Version::from_str(name).ok()
|
|
&& fs
|
|
.is_file(&dir.join(file_name).join(&entrypoint_path))
|
|
.await
|
|
{
|
|
versions.push((version, file_name.to_owned()));
|
|
} else {
|
|
to_delete.push(file_name.to_owned())
|
|
}
|
|
}
|
|
|
|
versions.sort();
|
|
let newest_version = if let Some((version, file_name)) = versions.last().cloned()
|
|
&& minimum_version.is_none_or(|minimum_version| version >= minimum_version)
|
|
{
|
|
versions.pop();
|
|
Some(file_name)
|
|
} else {
|
|
None
|
|
};
|
|
log::debug!("existing version of {package_name}: {newest_version:?}");
|
|
to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
|
|
|
|
cx.background_spawn({
|
|
let fs = fs.clone();
|
|
let dir = dir.clone();
|
|
async move {
|
|
for file_name in to_delete {
|
|
fs.remove_dir(
|
|
&dir.join(file_name),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
ignore_if_not_exists: false,
|
|
},
|
|
)
|
|
.await
|
|
.ok();
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
let version = if let Some(file_name) = newest_version {
|
|
cx.background_spawn({
|
|
let file_name = file_name.clone();
|
|
let dir = dir.clone();
|
|
let fs = fs.clone();
|
|
async move {
|
|
let latest_version = node_runtime
|
|
.npm_package_latest_version(&package_name)
|
|
.await
|
|
.ok();
|
|
if let Some(latest_version) = latest_version
|
|
&& &latest_version != &file_name.to_string_lossy()
|
|
{
|
|
let download_result = download_latest_version(
|
|
fs,
|
|
dir.clone(),
|
|
node_runtime,
|
|
package_name.clone(),
|
|
)
|
|
.await
|
|
.log_err();
|
|
if let Some(mut new_version_available) = new_version_available
|
|
&& download_result.is_some()
|
|
{
|
|
new_version_available.send(Some(latest_version)).ok();
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.detach();
|
|
file_name
|
|
} else {
|
|
if let Some(mut status_tx) = status_tx {
|
|
status_tx.send("Installing…".into()).ok();
|
|
}
|
|
let dir = dir.clone();
|
|
cx.background_spawn(download_latest_version(
|
|
fs.clone(),
|
|
dir.clone(),
|
|
node_runtime,
|
|
package_name.clone(),
|
|
))
|
|
.await?
|
|
.into()
|
|
};
|
|
|
|
let agent_server_path = dir.join(version).join(entrypoint_path);
|
|
let agent_server_path_exists = fs.is_file(&agent_server_path).await;
|
|
anyhow::ensure!(
|
|
agent_server_path_exists,
|
|
"Missing entrypoint path {} after installation",
|
|
agent_server_path.to_string_lossy()
|
|
);
|
|
|
|
anyhow::Ok(AgentServerCommand {
|
|
path: node_path,
|
|
args: vec![agent_server_path.to_string_lossy().into_owned()],
|
|
env: None,
|
|
})
|
|
})
|
|
}
|
|
|
|
fn find_bin_in_path(
|
|
bin_name: SharedString,
|
|
root_dir: PathBuf,
|
|
env: HashMap<String, String>,
|
|
cx: &mut AsyncApp,
|
|
) -> Task<Option<PathBuf>> {
|
|
cx.background_executor().spawn(async move {
|
|
let which_result = if cfg!(windows) {
|
|
which::which(bin_name.as_str())
|
|
} else {
|
|
let shell_path = env.get("PATH").cloned();
|
|
which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
|
|
};
|
|
|
|
if let Err(which::Error::CannotFindBinaryPath) = which_result {
|
|
return None;
|
|
}
|
|
|
|
which_result.log_err()
|
|
})
|
|
}
|
|
|
|
async fn download_latest_version(
|
|
fs: Arc<dyn Fs>,
|
|
dir: PathBuf,
|
|
node_runtime: NodeRuntime,
|
|
package_name: SharedString,
|
|
) -> Result<String> {
|
|
log::debug!("downloading latest version of {package_name}");
|
|
|
|
let tmp_dir = tempfile::tempdir_in(&dir)?;
|
|
|
|
node_runtime
|
|
.npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
|
|
.await?;
|
|
|
|
let version = node_runtime
|
|
.npm_package_installed_version(tmp_dir.path(), &package_name)
|
|
.await?
|
|
.context("expected package to be installed")?;
|
|
|
|
fs.rename(
|
|
&tmp_dir.keep(),
|
|
&dir.join(&version),
|
|
RenameOptions {
|
|
ignore_if_exists: true,
|
|
overwrite: true,
|
|
create_parents: false,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
anyhow::Ok(version)
|
|
}
|
|
|
|
struct RemoteExternalAgentServer {
|
|
project_id: u64,
|
|
upstream_client: Entity<RemoteClient>,
|
|
name: ExternalAgentServerName,
|
|
status_tx: Option<watch::Sender<SharedString>>,
|
|
new_version_available_tx: Option<watch::Sender<Option<String>>>,
|
|
}
|
|
|
|
impl ExternalAgentServer for RemoteExternalAgentServer {
|
|
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 project_id = self.project_id;
|
|
let name = self.name.to_string();
|
|
let upstream_client = self.upstream_client.downgrade();
|
|
let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
|
|
self.status_tx = status_tx;
|
|
self.new_version_available_tx = new_version_available_tx;
|
|
cx.spawn(async move |cx| {
|
|
let mut response = upstream_client
|
|
.update(cx, |upstream_client, _| {
|
|
upstream_client
|
|
.proto_client()
|
|
.request(proto::GetAgentServerCommand {
|
|
project_id,
|
|
name,
|
|
root_dir: root_dir.clone(),
|
|
})
|
|
})?
|
|
.await?;
|
|
let root_dir = response.root_dir;
|
|
response.env.extend(extra_env);
|
|
let command = upstream_client.update(cx, |client, _| {
|
|
client.build_command(
|
|
Some(response.path),
|
|
&response.args,
|
|
&response.env.into_iter().collect(),
|
|
Some(root_dir.clone()),
|
|
None,
|
|
)
|
|
})??;
|
|
Ok((
|
|
AgentServerCommand {
|
|
path: command.program.into(),
|
|
args: command.args,
|
|
env: Some(command.env),
|
|
},
|
|
root_dir,
|
|
response.login.map(SpawnInTerminal::from_proto),
|
|
))
|
|
})
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
struct LocalGemini {
|
|
fs: Arc<dyn Fs>,
|
|
node_runtime: NodeRuntime,
|
|
project_environment: Entity<ProjectEnvironment>,
|
|
custom_command: Option<AgentServerCommand>,
|
|
ignore_system_version: bool,
|
|
}
|
|
|
|
impl ExternalAgentServer for LocalGemini {
|
|
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 node_runtime = self.node_runtime.clone();
|
|
let project_environment = self.project_environment.downgrade();
|
|
let custom_command = self.custom_command.clone();
|
|
let ignore_system_version = self.ignore_system_version;
|
|
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| {
|
|
let mut env = project_environment
|
|
.update(cx, |project_environment, cx| {
|
|
project_environment.local_directory_environment(
|
|
&Shell::System,
|
|
root_dir.clone(),
|
|
cx,
|
|
)
|
|
})?
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let mut command = if let Some(mut custom_command) = custom_command {
|
|
env.extend(custom_command.env.unwrap_or_default());
|
|
custom_command.env = Some(env);
|
|
custom_command
|
|
} else if !ignore_system_version
|
|
&& let Some(bin) =
|
|
find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
|
|
{
|
|
AgentServerCommand {
|
|
path: bin,
|
|
args: Vec::new(),
|
|
env: Some(env),
|
|
}
|
|
} else {
|
|
let mut command = get_or_npm_install_builtin_agent(
|
|
GEMINI_NAME.into(),
|
|
"@google/gemini-cli".into(),
|
|
"node_modules/@google/gemini-cli/dist/index.js".into(),
|
|
if cfg!(windows) {
|
|
// v0.8.x on Windows has a bug that causes the initialize request to hang forever
|
|
Some("0.9.0".parse().unwrap())
|
|
} else {
|
|
Some("0.2.1".parse().unwrap())
|
|
},
|
|
status_tx,
|
|
new_version_available_tx,
|
|
fs,
|
|
node_runtime,
|
|
cx,
|
|
)
|
|
.await?;
|
|
command.env = Some(env);
|
|
command
|
|
};
|
|
|
|
// Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
|
|
let login = task::SpawnInTerminal {
|
|
command: Some(command.path.to_string_lossy().into_owned()),
|
|
args: command.args.clone(),
|
|
env: command.env.clone().unwrap_or_default(),
|
|
label: "gemini /auth".into(),
|
|
..Default::default()
|
|
};
|
|
|
|
command.env.get_or_insert_default().extend(extra_env);
|
|
command.args.push("--experimental-acp".into());
|
|
Ok((
|
|
command,
|
|
root_dir.to_string_lossy().into_owned(),
|
|
Some(login),
|
|
))
|
|
})
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
struct LocalClaudeCode {
|
|
fs: Arc<dyn Fs>,
|
|
node_runtime: NodeRuntime,
|
|
project_environment: Entity<ProjectEnvironment>,
|
|
custom_command: Option<AgentServerCommand>,
|
|
}
|
|
|
|
impl ExternalAgentServer for LocalClaudeCode {
|
|
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 node_runtime = self.node_runtime.clone();
|
|
let project_environment = self.project_environment.downgrade();
|
|
let custom_command = self.custom_command.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| {
|
|
let mut env = project_environment
|
|
.update(cx, |project_environment, cx| {
|
|
project_environment.local_directory_environment(
|
|
&Shell::System,
|
|
root_dir.clone(),
|
|
cx,
|
|
)
|
|
})?
|
|
.await
|
|
.unwrap_or_default();
|
|
env.insert("ANTHROPIC_API_KEY".into(), "".into());
|
|
|
|
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)
|
|
} else {
|
|
let mut command = get_or_npm_install_builtin_agent(
|
|
"claude-code-acp".into(),
|
|
"@zed-industries/claude-code-acp".into(),
|
|
"node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
|
|
Some("0.5.2".parse().unwrap()),
|
|
status_tx,
|
|
new_version_available_tx,
|
|
fs,
|
|
node_runtime,
|
|
cx,
|
|
)
|
|
.await?;
|
|
command.env = Some(env);
|
|
let login = command
|
|
.args
|
|
.first()
|
|
.and_then(|path| {
|
|
path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
|
|
})
|
|
.map(|path_prefix| task::SpawnInTerminal {
|
|
command: Some(command.path.to_string_lossy().into_owned()),
|
|
args: vec![
|
|
Path::new(path_prefix)
|
|
.join("@anthropic-ai/claude-agent-sdk/cli.js")
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
"/login".into(),
|
|
],
|
|
env: command.env.clone().unwrap_or_default(),
|
|
label: "claude /login".into(),
|
|
..Default::default()
|
|
});
|
|
(command, login)
|
|
};
|
|
|
|
command.env.get_or_insert_default().extend(extra_env);
|
|
Ok((
|
|
command,
|
|
root_dir.to_string_lossy().into_owned(),
|
|
login_command,
|
|
))
|
|
})
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
struct LocalCodex {
|
|
fs: Arc<dyn Fs>,
|
|
project_environment: Entity<ProjectEnvironment>,
|
|
http_client: Arc<dyn HttpClient>,
|
|
custom_command: Option<AgentServerCommand>,
|
|
is_remote: bool,
|
|
}
|
|
|
|
impl ExternalAgentServer for LocalCodex {
|
|
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 project_environment = self.project_environment.downgrade();
|
|
let http = self.http_client.clone();
|
|
let custom_command = self.custom_command.clone();
|
|
let root_dir: Arc<Path> = root_dir
|
|
.map(|root_dir| Path::new(root_dir))
|
|
.unwrap_or(paths::home_dir())
|
|
.into();
|
|
let is_remote = self.is_remote;
|
|
|
|
cx.spawn(async move |cx| {
|
|
let mut env = project_environment
|
|
.update(cx, |project_environment, cx| {
|
|
project_environment.local_directory_environment(
|
|
&Shell::System,
|
|
root_dir.clone(),
|
|
cx,
|
|
)
|
|
})?
|
|
.await
|
|
.unwrap_or_default();
|
|
if is_remote {
|
|
env.insert("NO_BROWSER".to_owned(), "1".to_owned());
|
|
}
|
|
|
|
let mut command = if let Some(mut custom_command) = custom_command {
|
|
env.extend(custom_command.env.unwrap_or_default());
|
|
custom_command.env = Some(env);
|
|
custom_command
|
|
} else {
|
|
let dir = paths::external_agents_dir().join(CODEX_NAME);
|
|
fs.create_dir(&dir).await?;
|
|
|
|
// Find or install the latest Codex release (no update checks for now).
|
|
let release = ::http_client::github::latest_github_release(
|
|
CODEX_ACP_REPO,
|
|
true,
|
|
false,
|
|
http.clone(),
|
|
)
|
|
.await
|
|
.context("fetching Codex latest release")?;
|
|
|
|
let version_dir = dir.join(&release.tag_name);
|
|
if !fs.is_dir(&version_dir).await {
|
|
if let Some(mut status_tx) = status_tx {
|
|
status_tx.send("Installing…".into()).ok();
|
|
}
|
|
|
|
let tag = release.tag_name.clone();
|
|
let version_number = tag.trim_start_matches('v');
|
|
let asset_name = asset_name(version_number)
|
|
.context("codex acp is not supported for this architecture")?;
|
|
let asset = release
|
|
.assets
|
|
.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,
|
|
digest,
|
|
&version_dir,
|
|
if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
|
|
AssetKind::Zip
|
|
} else {
|
|
AssetKind::TarGz
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
// remove older versions
|
|
util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
|
|
}
|
|
|
|
let bin_name = if cfg!(windows) {
|
|
"codex-acp.exe"
|
|
} else {
|
|
"codex-acp"
|
|
};
|
|
let bin_path = version_dir.join(bin_name);
|
|
anyhow::ensure!(
|
|
fs.is_file(&bin_path).await,
|
|
"Missing Codex binary at {} after installation",
|
|
bin_path.to_string_lossy()
|
|
);
|
|
|
|
let mut cmd = AgentServerCommand {
|
|
path: bin_path,
|
|
args: Vec::new(),
|
|
env: None,
|
|
};
|
|
cmd.env = Some(env);
|
|
cmd
|
|
};
|
|
|
|
command.env.get_or_insert_default().extend(extra_env);
|
|
Ok((command, root_dir.to_string_lossy().into_owned(), None))
|
|
})
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
|
|
|
|
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") {
|
|
"aarch64"
|
|
} else {
|
|
return None;
|
|
};
|
|
|
|
let platform = if cfg!(target_os = "macos") {
|
|
"apple-darwin"
|
|
} else if cfg!(target_os = "windows") {
|
|
"pc-windows-msvc"
|
|
} else if cfg!(target_os = "linux") {
|
|
"unknown-linux-gnu"
|
|
} else {
|
|
return None;
|
|
};
|
|
|
|
// Only Windows x86_64 uses .zip in release assets
|
|
let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
|
|
"zip"
|
|
} else {
|
|
"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>,
|
|
node_runtime: NodeRuntime,
|
|
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 node_runtime = self.node_runtime.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.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::external_agents_dir().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;
|
|
|
|
let cmd_path = if cmd == "node" {
|
|
// Use Zed's managed Node.js runtime
|
|
node_runtime.binary_path().await?
|
|
} else {
|
|
if cmd.contains("..") {
|
|
anyhow::bail!("command path cannot contain '..': {}", cmd);
|
|
}
|
|
|
|
if cmd.starts_with("./") || cmd.starts_with(".\\") {
|
|
// Relative to extraction directory
|
|
let cmd_path = version_dir.join(&cmd[2..]);
|
|
anyhow::ensure!(
|
|
fs.is_file(&cmd_path).await,
|
|
"Missing command {} after extraction",
|
|
cmd_path.to_string_lossy()
|
|
);
|
|
cmd_path
|
|
} else {
|
|
// On PATH
|
|
anyhow::bail!("command must be relative (start with './'): {}", cmd);
|
|
}
|
|
};
|
|
|
|
let command = AgentServerCommand {
|
|
path: cmd_path,
|
|
args: target_config.args.clone(),
|
|
env: Some(env),
|
|
};
|
|
|
|
Ok((command, version_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,
|
|
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 mut command = self.command.clone();
|
|
let root_dir: Arc<Path> = root_dir
|
|
.map(|root_dir| Path::new(root_dir))
|
|
.unwrap_or(paths::home_dir())
|
|
.into();
|
|
let project_environment = self.project_environment.downgrade();
|
|
cx.spawn(async move |cx| {
|
|
let mut env = project_environment
|
|
.update(cx, |project_environment, cx| {
|
|
project_environment.local_directory_environment(
|
|
&Shell::System,
|
|
root_dir.clone(),
|
|
cx,
|
|
)
|
|
})?
|
|
.await
|
|
.unwrap_or_default();
|
|
env.extend(command.env.unwrap_or_default());
|
|
env.extend(extra_env);
|
|
command.env = Some(env);
|
|
Ok((command, root_dir.to_string_lossy().into_owned(), None))
|
|
})
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
pub const GEMINI_NAME: &'static str = "gemini";
|
|
pub const CLAUDE_CODE_NAME: &'static str = "claude";
|
|
pub const CODEX_NAME: &'static str = "codex";
|
|
|
|
#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
|
|
pub struct AllAgentServersSettings {
|
|
pub gemini: Option<BuiltinAgentServerSettings>,
|
|
pub claude: Option<BuiltinAgentServerSettings>,
|
|
pub codex: Option<BuiltinAgentServerSettings>,
|
|
pub custom: HashMap<SharedString, CustomAgentServerSettings>,
|
|
}
|
|
#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
|
|
pub struct BuiltinAgentServerSettings {
|
|
pub path: Option<PathBuf>,
|
|
pub args: Option<Vec<String>>,
|
|
pub env: Option<HashMap<String, String>>,
|
|
pub ignore_system_version: Option<bool>,
|
|
pub default_mode: Option<String>,
|
|
pub default_model: Option<String>,
|
|
}
|
|
|
|
impl BuiltinAgentServerSettings {
|
|
pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
|
|
self.path.map(|path| AgentServerCommand {
|
|
path,
|
|
args: self.args.unwrap_or_default(),
|
|
env: self.env,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
|
|
fn from(value: settings::BuiltinAgentServerSettings) -> Self {
|
|
BuiltinAgentServerSettings {
|
|
path: value
|
|
.path
|
|
.map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
|
|
args: value.args,
|
|
env: value.env,
|
|
ignore_system_version: value.ignore_system_version,
|
|
default_mode: value.default_mode,
|
|
default_model: value.default_model,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<AgentServerCommand> for BuiltinAgentServerSettings {
|
|
fn from(value: AgentServerCommand) -> Self {
|
|
BuiltinAgentServerSettings {
|
|
path: Some(value.path),
|
|
args: Some(value.args),
|
|
env: value.env,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, JsonSchema, Debug, PartialEq)]
|
|
pub enum CustomAgentServerSettings {
|
|
Custom {
|
|
command: AgentServerCommand,
|
|
/// The default mode to use for this agent.
|
|
///
|
|
/// Note: Not only all agents support modes.
|
|
///
|
|
/// Default: None
|
|
default_mode: Option<String>,
|
|
/// The default model to use for this agent.
|
|
///
|
|
/// This should be the model ID as reported by the agent.
|
|
///
|
|
/// Default: None
|
|
default_model: Option<String>,
|
|
},
|
|
Extension {
|
|
/// The default mode to use for this agent.
|
|
///
|
|
/// Note: Not only all agents support modes.
|
|
///
|
|
/// Default: None
|
|
default_mode: Option<String>,
|
|
/// The default model to use for this agent.
|
|
///
|
|
/// This should be the model ID as reported by the agent.
|
|
///
|
|
/// Default: None
|
|
default_model: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl CustomAgentServerSettings {
|
|
pub fn command(&self) -> Option<&AgentServerCommand> {
|
|
match self {
|
|
CustomAgentServerSettings::Custom { command, .. } => Some(command),
|
|
CustomAgentServerSettings::Extension { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub fn default_mode(&self) -> Option<&str> {
|
|
match self {
|
|
CustomAgentServerSettings::Custom { default_mode, .. }
|
|
| CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
|
|
}
|
|
}
|
|
|
|
pub fn default_model(&self) -> Option<&str> {
|
|
match self {
|
|
CustomAgentServerSettings::Custom { default_model, .. }
|
|
| CustomAgentServerSettings::Extension { default_model, .. } => {
|
|
default_model.as_deref()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
|
|
fn from(value: settings::CustomAgentServerSettings) -> Self {
|
|
match value {
|
|
settings::CustomAgentServerSettings::Custom {
|
|
path,
|
|
args,
|
|
env,
|
|
default_mode,
|
|
default_model,
|
|
} => CustomAgentServerSettings::Custom {
|
|
command: AgentServerCommand {
|
|
path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
|
|
args,
|
|
env,
|
|
},
|
|
default_mode,
|
|
default_model,
|
|
},
|
|
settings::CustomAgentServerSettings::Extension {
|
|
default_mode,
|
|
default_model,
|
|
} => CustomAgentServerSettings::Extension {
|
|
default_mode,
|
|
default_model,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl settings::Settings for AllAgentServersSettings {
|
|
fn from_settings(content: &settings::SettingsContent) -> Self {
|
|
let agent_settings = content.agent_servers.clone().unwrap();
|
|
Self {
|
|
gemini: agent_settings.gemini.map(Into::into),
|
|
claude: agent_settings.claude.map(Into::into),
|
|
codex: agent_settings.codex.map(Into::into),
|
|
custom: agent_settings
|
|
.custom
|
|
.into_iter()
|
|
.map(|(k, v)| (k, v.into()))
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod extension_agent_tests {
|
|
use crate::worktree_store::WorktreeStore;
|
|
|
|
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,
|
|
env: Default::default(),
|
|
},
|
|
);
|
|
|
|
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 worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
|
|
let project_environment = cx.new(|cx| {
|
|
crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
|
|
});
|
|
|
|
let agent = LocalExtensionArchiveAgent {
|
|
fs,
|
|
http_client,
|
|
node_runtime: node_runtime::NodeRuntime::unavailable(),
|
|
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,
|
|
env: Default::default(),
|
|
},
|
|
);
|
|
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,
|
|
env: Default::default(),
|
|
},
|
|
);
|
|
|
|
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");
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
|
|
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
|
let http_client = http_client::FakeHttpClient::with_404_response();
|
|
let node_runtime = NodeRuntime::unavailable();
|
|
let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
|
|
let project_environment = cx.new(|cx| {
|
|
crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
|
|
});
|
|
|
|
let agent = LocalExtensionArchiveAgent {
|
|
fs: fs.clone(),
|
|
http_client,
|
|
node_runtime,
|
|
project_environment,
|
|
extension_id: Arc::from("node-extension"),
|
|
agent_id: Arc::from("node-agent"),
|
|
targets: {
|
|
let mut map = HashMap::default();
|
|
map.insert(
|
|
"darwin-aarch64".to_string(),
|
|
extension::TargetConfig {
|
|
archive: "https://example.com/node-agent.zip".into(),
|
|
cmd: "node".into(),
|
|
args: vec!["index.js".into()],
|
|
sha256: None,
|
|
env: Default::default(),
|
|
},
|
|
);
|
|
map
|
|
},
|
|
env: HashMap::default(),
|
|
};
|
|
|
|
// Verify that when cmd is "node", it attempts to use the node runtime
|
|
assert_eq!(agent.extension_id.as_ref(), "node-extension");
|
|
assert_eq!(agent.agent_id.as_ref(), "node-agent");
|
|
|
|
let target = agent.targets.get("darwin-aarch64").unwrap();
|
|
assert_eq!(target.cmd, "node");
|
|
assert_eq!(target.args, vec!["index.js"]);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
|
|
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
|
let http_client = http_client::FakeHttpClient::with_404_response();
|
|
let node_runtime = NodeRuntime::unavailable();
|
|
let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
|
|
let project_environment = cx.new(|cx| {
|
|
crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
|
|
});
|
|
|
|
let agent = LocalExtensionArchiveAgent {
|
|
fs: fs.clone(),
|
|
http_client,
|
|
node_runtime,
|
|
project_environment,
|
|
extension_id: Arc::from("test-ext"),
|
|
agent_id: Arc::from("test-agent"),
|
|
targets: {
|
|
let mut map = HashMap::default();
|
|
map.insert(
|
|
"darwin-aarch64".to_string(),
|
|
extension::TargetConfig {
|
|
archive: "https://example.com/test.zip".into(),
|
|
cmd: "node".into(),
|
|
args: vec![
|
|
"server.js".into(),
|
|
"--config".into(),
|
|
"./config.json".into(),
|
|
],
|
|
sha256: None,
|
|
env: Default::default(),
|
|
},
|
|
);
|
|
map
|
|
},
|
|
env: HashMap::default(),
|
|
};
|
|
|
|
// Verify the agent is configured with relative paths in args
|
|
let target = agent.targets.get("darwin-aarch64").unwrap();
|
|
assert_eq!(target.args[0], "server.js");
|
|
assert_eq!(target.args[2], "./config.json");
|
|
// These relative paths will resolve relative to the extraction directory
|
|
// when the command is executed
|
|
}
|
|
|
|
#[test]
|
|
fn test_tilde_expansion_in_settings() {
|
|
let settings = settings::BuiltinAgentServerSettings {
|
|
path: Some(PathBuf::from("~/bin/agent")),
|
|
args: Some(vec!["--flag".into()]),
|
|
env: None,
|
|
ignore_system_version: None,
|
|
default_mode: None,
|
|
default_model: None,
|
|
};
|
|
|
|
let BuiltinAgentServerSettings { path, .. } = settings.into();
|
|
|
|
let path = path.unwrap();
|
|
assert!(
|
|
!path.to_string_lossy().starts_with("~"),
|
|
"Tilde should be expanded for builtin agent path"
|
|
);
|
|
|
|
let settings = settings::CustomAgentServerSettings::Custom {
|
|
path: PathBuf::from("~/custom/agent"),
|
|
args: vec!["serve".into()],
|
|
env: None,
|
|
default_mode: None,
|
|
default_model: None,
|
|
};
|
|
|
|
let converted: CustomAgentServerSettings = settings.into();
|
|
let CustomAgentServerSettings::Custom {
|
|
command: AgentServerCommand { path, .. },
|
|
..
|
|
} = converted
|
|
else {
|
|
panic!("Expected Custom variant");
|
|
};
|
|
|
|
assert!(
|
|
!path.to_string_lossy().starts_with("~"),
|
|
"Tilde should be expanded for custom agent path"
|
|
);
|
|
}
|
|
}
|