Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 130 additions & 3 deletions crates/ai/src/acp/bridge_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ pub(super) async fn initialize_worker(
SessionBootstrapContext {
auth_methods,
supports_session_resume,
default_mode: config.default_mode.clone(),
default_model: config.default_model.clone(),
map_config_options,
child: &mut child,
io_handle: &io_handle,
Expand Down Expand Up @@ -143,6 +145,8 @@ where
{
auth_methods: Vec<acp::AuthMethod>,
supports_session_resume: bool,
default_mode: Option<String>,
default_model: Option<String>,
map_config_options: F,
child: &'a mut Child,
io_handle: &'a tokio::task::JoinHandle<()>,
Expand Down Expand Up @@ -340,6 +344,14 @@ async fn bootstrap_session(

match load_result {
Ok(Ok(load_response)) => {
apply_session_defaults(
connection.clone(),
acp::SessionId::new(existing_session_id.clone()),
ctx.default_mode.as_deref(),
ctx.default_model.as_deref(),
load_response.config_options.as_ref(),
)
.await;
log::info!("ACP session loaded: {}", existing_session_id);
client.set_session_id(existing_session_id.clone()).await;
return Ok(SessionBootstrap {
Expand Down Expand Up @@ -375,6 +387,14 @@ async fn bootstrap_session(

match resume_result {
Ok(Ok(resume_response)) => {
apply_session_defaults(
connection.clone(),
acp::SessionId::new(existing_session_id.clone()),
ctx.default_mode.as_deref(),
ctx.default_model.as_deref(),
resume_response.config_options.as_ref(),
)
.await;
log::info!("ACP session resumed: {}", existing_session_id);
client.set_session_id(existing_session_id.clone()).await;
return Ok(SessionBootstrap {
Expand Down Expand Up @@ -478,6 +498,14 @@ async fn bootstrap_session(
};

log::info!("ACP session created: {}", session.session_id);
apply_session_defaults(
connection.clone(),
session.session_id.clone(),
ctx.default_mode.as_deref(),
ctx.default_model.as_deref(),
session.config_options.as_ref(),
)
.await;
client.set_session_id(session.session_id.to_string()).await;

Ok(SessionBootstrap {
Expand All @@ -491,7 +519,7 @@ async fn create_session(
connection: Arc<acp::ClientSideConnection>,
cwd: PathBuf,
) -> Result<Result<acp::NewSessionResponse, acp::Error>, tokio::time::error::Elapsed> {
let session_request = acp::NewSessionRequest::new(cwd);
let session_request = new_session_request(cwd);
tokio::time::timeout(
std::time::Duration::from_secs(30),
connection.new_session(session_request),
Expand All @@ -504,7 +532,7 @@ async fn load_session(
cwd: PathBuf,
existing_session_id: String,
) -> Result<Result<acp::LoadSessionResponse, acp::Error>, tokio::time::error::Elapsed> {
let request = acp::LoadSessionRequest::new(existing_session_id, cwd);
let request = load_session_request(existing_session_id, cwd);
tokio::time::timeout(
std::time::Duration::from_secs(30),
connection.load_session(request),
Expand All @@ -517,14 +545,82 @@ async fn resume_session(
cwd: PathBuf,
existing_session_id: String,
) -> Result<Result<acp::ResumeSessionResponse, acp::Error>, tokio::time::error::Elapsed> {
let request = acp::ResumeSessionRequest::new(existing_session_id, cwd);
let request = resume_session_request(existing_session_id, cwd);
tokio::time::timeout(
std::time::Duration::from_secs(30),
connection.resume_session(request),
)
.await
}

fn new_session_request(cwd: PathBuf) -> acp::NewSessionRequest {
acp::NewSessionRequest::new(cwd)
}

fn load_session_request(existing_session_id: String, cwd: PathBuf) -> acp::LoadSessionRequest {
acp::LoadSessionRequest::new(existing_session_id, cwd)
}

fn resume_session_request(existing_session_id: String, cwd: PathBuf) -> acp::ResumeSessionRequest {
acp::ResumeSessionRequest::new(existing_session_id, cwd)
}

async fn apply_session_defaults(
connection: Arc<acp::ClientSideConnection>,
session_id: acp::SessionId,
default_mode: Option<&str>,
default_model: Option<&str>,
config_options: Option<&Vec<acp::SessionConfigOption>>,
) {
if let Some(mode_id) = default_mode.filter(|mode| !mode.trim().is_empty())
&& let Err(error) = connection
.set_session_mode(acp::SetSessionModeRequest::new(
session_id.clone(),
mode_id.to_string(),
))
.await
{
log::warn!("Failed to apply ACP default mode '{}': {}", mode_id, error);
}

let Some(model_id) = default_model.filter(|model| !model.trim().is_empty()) else {
return;
};
let Some(config_id) = model_config_option_id(config_options) else {
log::debug!(
"ACP default model '{}' configured, but the agent did not expose a model config option",
model_id
);
return;
};

if let Err(error) = connection
.set_session_config_option(acp::SetSessionConfigOptionRequest::new(
session_id,
config_id,
model_id.to_string(),
))
.await
{
log::warn!(
"Failed to apply ACP default model '{}': {}",
model_id,
error
);
}
}

fn model_config_option_id(
config_options: Option<&Vec<acp::SessionConfigOption>>,
) -> Option<String> {
config_options?
.iter()
.find(|option| {
option.id.to_string() == "model" || option.category.as_deref() == Some("model")
})
.map(|option| option.id.to_string())
}

fn map_mode_state(modes: acp::SessionModeState) -> SessionModeState {
SessionModeState {
current_mode_id: Some(modes.current_mode_id.to_string()),
Expand Down Expand Up @@ -570,3 +666,34 @@ fn emit_initial_session_state(
log::warn!("Failed to emit initial session config options: {}", e);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn new_session_request_sets_cwd() {
let request = new_session_request(PathBuf::from("/repo"));

assert_eq!(request.cwd, PathBuf::from("/repo"));
assert!(request.mcp_servers.is_empty());
}

#[test]
fn load_session_request_sets_session_and_cwd() {
let request = load_session_request("session-1".to_string(), PathBuf::from("/repo"));

assert_eq!(request.session_id, acp::SessionId::new("session-1"));
assert_eq!(request.cwd, PathBuf::from("/repo"));
assert!(request.mcp_servers.is_empty());
}

#[test]
fn resume_session_request_sets_session_and_cwd() {
let request = resume_session_request("session-1".to_string(), PathBuf::from("/repo"));

assert_eq!(request.session_id, acp::SessionId::new("session-1"));
assert_eq!(request.cwd, PathBuf::from("/repo"));
assert!(request.mcp_servers.is_empty());
}
}
8 changes: 8 additions & 0 deletions crates/ai/src/acp/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ impl AgentRegistry {
continue;
}

if let Some(path) = config.binary_path.as_ref().map(PathBuf::from)
&& path.is_file()
{
config.installed = true;
config.binary_path = Some(path.to_string_lossy().to_string());
continue;
}

if let Some(path) = find_binary(&config.binary_name) {
config.installed = true;
config.binary_path = Some(path.to_string_lossy().to_string());
Expand Down
4 changes: 4 additions & 0 deletions crates/ai/src/acp/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ pub struct AgentConfig {
pub binary_path: Option<String>,
pub args: Vec<String>,
pub env_vars: HashMap<String, String>,
pub default_mode: Option<String>,
pub default_model: Option<String>,
pub icon: Option<String>,
pub description: Option<String>,
pub installed: bool,
Expand All @@ -224,6 +226,8 @@ impl AgentConfig {
binary_path: None,
args: Vec::new(),
env_vars: HashMap::new(),
default_mode: None,
default_model: None,
icon: None,
description: None,
installed: false,
Expand Down
86 changes: 84 additions & 2 deletions crates/tooling/src/installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ impl ToolInstaller {
}

fn known_node_companion_packages(package: &str) -> &'static [&'static str] {
match package {
match Self::node_package_name(package).as_str() {
// typescript-language-server declares TypeScript as a peer dependency
// and exits during LSP initialize if it cannot resolve it locally.
"typescript-language-server" => &["typescript"],
Expand All @@ -129,6 +129,35 @@ impl ToolInstaller {
packages
}

fn node_package_name(package_spec: &str) -> String {
let spec = package_spec.trim();
if let Some(scoped) = spec.strip_prefix('@') {
let mut segments = scoped.splitn(3, '/');
let Some(scope) = segments.next() else {
return spec.to_string();
};
let Some(name_and_version) = segments.next() else {
return spec.to_string();
};
let name = name_and_version
.rsplit_once('@')
.map(|(name, _)| name)
.unwrap_or(name_and_version);
if name.is_empty() {
spec.to_string()
} else {
format!("@{scope}/{name}")
}
} else {
spec
.rsplit_once('@')
.map(|(name, _)| name)
.filter(|name| !name.is_empty())
.unwrap_or(spec)
.to_string()
}
}

fn node_companion_packages_to_validate(
package: &str,
companion_packages: &[String],
Expand Down Expand Up @@ -219,7 +248,9 @@ impl ToolInstaller {
package: &str,
command_name: &str,
) -> Option<PathBuf> {
let package_root = package_dir.join("node_modules").join(package);
let package_root = package_dir
.join("node_modules")
.join(Self::node_package_name(package));
Self::resolve_node_package_entrypoint_from_root(&package_root, command_name, true)
.filter(|path| path.exists())
}
Expand Down Expand Up @@ -1304,6 +1335,22 @@ mod tests {
assert!(ready.is_ok());
}

#[test]
fn resolves_node_package_name_from_versioned_specs() {
assert_eq!(
ToolInstaller::node_package_name("typescript-language-server@4.4.1"),
"typescript-language-server"
);
assert_eq!(
ToolInstaller::node_package_name("@vtsls/language-server@0.2.9"),
"@vtsls/language-server"
);
assert_eq!(
ToolInstaller::node_package_name("@vtsls/language-server"),
"@vtsls/language-server"
);
}

#[test]
fn resolves_node_bin_shim_when_present() {
let temp = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -1358,6 +1405,41 @@ mod tests {
assert_eq!(resolved.as_deref(), Some(entrypoint.as_path()));
}

#[test]
fn resolves_versioned_node_package_entrypoint() {
let temp = tempfile::tempdir().unwrap();
let package_dir = temp
.path()
.join("npm")
.join("@agentclientprotocol")
.join("claude-agent-acp@0.33.1");
let package_root = package_dir
.join("node_modules")
.join("@agentclientprotocol")
.join("claude-agent-acp");
let entrypoint = package_root.join("bin").join("claude-agent-acp.js");
fs::create_dir_all(entrypoint.parent().unwrap()).unwrap();
fs::write(
package_root.join("package.json"),
r#"{
"name": "@agentclientprotocol/claude-agent-acp",
"bin": {
"claude-agent-acp": "./bin/claude-agent-acp.js"
}
}"#,
)
.unwrap();
fs::write(&entrypoint, "").unwrap();

let resolved = ToolInstaller::resolve_node_package_binary(
&package_dir,
"@agentclientprotocol/claude-agent-acp@0.33.1",
"claude-agent-acp",
);

assert_eq!(resolved.as_deref(), Some(entrypoint.as_path()));
}

#[test]
fn writes_ruby_wrapper_for_managed_gem_executable() {
let temp = tempfile::tempdir().unwrap();
Expand Down
3 changes: 1 addition & 2 deletions crates/tooling/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,7 @@ mod tests {

#[test]
fn resolves_url_placeholders() {
let template =
"https://example.com/${os}/${arch}/${platformArch}/${targetOs}/${targetArch}.${archiveExt}?v=${version}";
let template = "https://example.com/${os}/${arch}/${platformArch}/${targetOs}/${targetArch}.${archiveExt}?v=${version}";
let resolved = ToolRegistry::resolve_url_template(template);

assert!(!resolved.contains("${"));
Expand Down
Loading
Loading