use std::collections::HashSet;
use std::path::PathBuf;
use alog::{MessageLevel, alog_channel, use_channel};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::capabilities::{
Binding, BindingType, Capability, KnownSubAgent, McpBinding, SubAgentBinding, ToolName,
};
use crate::launchers::base::HasLauncherMetadata as HasClaudeLauncherMetadata;
use crate::launchers::base::{EnvBinding, LaunchContext, Launcher, LauncherMetadata, run_command};
use crate::launchers::shared::mcp_cli::{
mcp_binding_request, register_mcp_server, remove_mcp_server,
};
use crate::proxy::ProxyHandle;
use crate::registry::ConfigConstructable;
use crate::utils::resolve_shell_command;
use crate::utils::ui::Ui;
use_channel!("CLAUD");
#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
pub struct ClaudeLauncherConfig {
#[serde(default)]
pub command_path: Option<String>,
}
pub struct ClaudeLauncher {
instance_id: String,
config: ClaudeLauncherConfig,
bound_agent_model: Option<crate::capabilities::AgentModelBinding>,
bound_mcp_bindings: Vec<(String, McpBinding)>,
bound_sub_agents: Vec<(String, SubAgentBinding)>,
model_proxy: Option<ProxyHandle>,
}
impl ConfigConstructable for ClaudeLauncher {
type Config = ClaudeLauncherConfig;
fn new(
instance_id: &str,
cfg: &serde_json::Value,
global_config: &crate::config::Config,
) -> Self {
let config: ClaudeLauncherConfig = serde_json::from_value(cfg.clone()).unwrap_or_default();
Self {
instance_id: instance_id.to_string(),
config,
bound_agent_model: None,
bound_mcp_bindings: vec![],
bound_sub_agents: vec![],
model_proxy: global_config.model_proxy.clone(),
}
}
}
impl crate::registry::Named for ClaudeLauncher {
fn instance_id(&self) -> &str {
&self.instance_id
}
}
#[async_trait]
impl Launcher for ClaudeLauncher {
fn name(&self) -> &str {
"Claude CLI"
}
fn command(&self) -> &str {
self.config.command_path.as_deref().unwrap_or("claude")
}
async fn bind_capability(&mut self, capability: &dyn Capability) -> anyhow::Result<()> {
let supported = Self::metadata().supported_capabilities;
let capability_types = capability.binding_types();
if !capability_types.is_subset(&supported) {
anyhow::bail!(
"capability supports {:?} which this launcher does not support",
capability_types.difference(&supported).collect::<Vec<_>>()
);
}
if capability_types.contains(&BindingType::Mcp) {
let binding = capability.bind(mcp_binding_request()).await?;
match binding {
Binding::Mcp(binding) => {
self.bound_mcp_bindings
.push((capability.instance_id().to_string(), binding));
}
other => anyhow::bail!("expected an Mcp binding, got {:?}", other.binding_type()),
}
return Ok(());
}
if capability_types.contains(&BindingType::SubAgent) {
let request = crate::capabilities::BindingRequest::SubAgent(
crate::capabilities::SubAgentBindingRequest {
api_type: crate::providers::ApiType::Anthropic,
},
);
let binding = capability.bind(request).await?;
match binding {
Binding::SubAgent(binding) => {
self.bound_sub_agents
.push((capability.instance_id().to_string(), binding));
}
other => anyhow::bail!(
"expected a SubAgent binding, got {:?}",
other.binding_type()
),
}
return Ok(());
}
let request = crate::capabilities::BindingRequest::AgentModel(
crate::capabilities::AgentModelBindingRequest {
api_type: crate::providers::ApiType::Anthropic,
},
);
let binding = capability.bind(request).await?;
match binding {
Binding::AgentModel(binding) => {
self.bound_agent_model = Some(binding);
}
other => anyhow::bail!(
"expected an AgentModel binding, got {:?}",
other.binding_type()
),
}
Ok(())
}
fn validate_command(&self) -> anyhow::Result<PathBuf> {
resolve_shell_command(&self.config.command_path, "claude")
}
async fn env_overlay(&self, _ctx: &LaunchContext) -> anyhow::Result<Vec<EnvBinding>> {
if let Some(binding) = &self.bound_agent_model {
let mut api_key_val = match &binding.api_key {
Some(api_key) => api_key.clone().0,
_ => "".to_string(),
};
if api_key_val.is_empty() {
api_key_val = "unset".to_string(); }
let mut bindings = vec![
EnvBinding {
key: "ANTHROPIC_BASE_URL".to_string(),
value: binding.base_url.clone(),
},
EnvBinding {
key: "ANTHROPIC_MODEL".to_string(),
value: binding.model_name.clone(),
},
EnvBinding {
key: "CLAUDE_CODE_MAX_CONTEXT_TOKENS".to_string(),
value: binding
.context_length
.map_or(String::new(), |v| v.to_string()),
},
EnvBinding {
key: "ANTHROPIC_AUTH_TOKEN".to_string(),
value: api_key_val,
},
];
if let Some(ref headers) = binding.custom_headers {
if !headers.is_empty() {
let mut header_pairs: Vec<(String, String)> = headers
.iter()
.map(|(k, v)| {
(
k.clone(),
serde_json::to_value(v)
.unwrap()
.as_str()
.unwrap()
.to_string(),
)
})
.collect();
header_pairs.sort_by(|a, b| a.0.cmp(&b.0));
let header_lines: Vec<String> = header_pairs
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect();
bindings.push(EnvBinding {
key: "ANTHROPIC_CUSTOM_HEADERS".to_string(),
value: header_lines.join("\n"),
});
}
}
Ok(bindings)
} else {
Ok(vec![])
}
}
fn map_tool_name(&self, tool: &ToolName) -> Option<String> {
Some(match tool {
ToolName::FileRead => "Read".to_string(),
ToolName::FileWrite => "Write".to_string(),
ToolName::FileEdit => "Edit".to_string(),
ToolName::Search => "Grep".to_string(),
ToolName::FileSearch => "Glob".to_string(),
ToolName::Shell => "Bash".to_string(),
ToolName::WebFetch => "WebFetch".to_string(),
ToolName::WebSearch => "WebSearch".to_string(),
ToolName::Mcp { server, tool: None } => format!("mcp__{server}"),
ToolName::Mcp {
server,
tool: Some(t),
} => format!("mcp__{server}__{t}"),
ToolName::Other(raw) => raw.clone(),
})
}
async fn launch(
&self,
args: &[String],
ctx: &LaunchContext,
ui: &dyn Ui,
) -> anyhow::Result<std::process::ExitStatus> {
let binary = self.validate_command()?;
let mut overlay = self.env_overlay(ctx).await?;
self.wire_model_proxy(ctx, &mut overlay)?;
alog_channel!(MessageLevel::Debug4, "Env overlay: {:#?}", overlay);
let mut full_args = Vec::new();
if !self.bound_sub_agents.is_empty() {
full_args.push("--agents".to_string());
full_args.push(self.build_agents_json(ui));
}
full_args.extend_from_slice(args);
const SCOPE: &[&str] = &["--scope", "local"];
for (name, binding) in &self.bound_mcp_bindings {
register_mcp_server(&binary, name, binding, SCOPE, ctx, ui)?;
}
alog_channel!(MessageLevel::Debug3, "Binary: {:#?}", &binary);
alog_channel!(MessageLevel::Debug3, "Full args: {:#?}", full_args);
let result = run_command(binary.clone(), &overlay, &full_args, ctx, ui).await;
for (name, _) in &self.bound_mcp_bindings {
remove_mcp_server(&binary, name, SCOPE, ctx, ui);
}
result
}
}
impl ClaudeLauncher {
fn wire_model_proxy(
&self,
ctx: &LaunchContext,
overlay: &mut Vec<EnvBinding>,
) -> anyhow::Result<()> {
match &self.model_proxy {
Some(handle) => {
if let Some(main) = &self.bound_agent_model
&& let Err(e) = handle.set_default_from_route(&main.model_name)
{
alog_channel!(MessageLevel::Warning, "failed to set default route: {e}");
}
set_env_binding(overlay, "ANTHROPIC_BASE_URL", handle.local_base_url.clone());
}
None if !self.bound_sub_agents.is_empty() && ctx.dry_run => {
set_env_binding(
overlay,
"ANTHROPIC_BASE_URL",
"<sub-agent router: not started under --dry-run>".to_string(),
);
}
None if !self.bound_sub_agents.is_empty() => {
alog_channel!(
MessageLevel::Warning,
"sub-agents bound but no proxy handle available; sub-agent routing disabled for this launch"
);
}
None => {}
}
Ok(())
}
fn build_agents_json(&self, ui: &dyn Ui) -> String {
let agents: serde_json::Map<String, serde_json::Value> = self
.bound_sub_agents
.iter()
.map(|(name, binding)| {
let mut entry = serde_json::json!({
"description": binding.description,
"prompt": binding.prompt,
"model": binding.model.model_name,
});
let mapped_tools: Vec<String> = binding
.tools
.iter()
.filter_map(|tool| {
let mapped = self.map_tool_name(tool);
if mapped.is_none() {
ui.warn(&format!(
"sub-agent '{name}': tool {tool:?} has no mapping for the claude launcher, skipping"
));
}
mapped
})
.collect();
if !mapped_tools.is_empty() {
entry["tools"] = serde_json::json!(mapped_tools);
}
let mapped_name = match binding.known_type {
Some(KnownSubAgent::Explore) => "Explore".to_string(),
Some(KnownSubAgent::Plan) => "Plan".to_string(),
_ => name.clone(),
};
alog_channel!(MessageLevel::Debug2, "Using agent name {} for {}", &mapped_name, &name);
(mapped_name, entry)
})
.collect();
serde_json::Value::Object(agents).to_string()
}
}
fn set_env_binding(overlay: &mut Vec<EnvBinding>, key: &str, value: String) {
match overlay.iter_mut().find(|b| b.key == key) {
Some(binding) => binding.value = value,
None => overlay.push(EnvBinding {
key: key.to_string(),
value,
}),
}
}
impl HasClaudeLauncherMetadata for ClaudeLauncher {
fn metadata() -> LauncherMetadata {
LauncherMetadata {
name: "Claude CLI".to_string(),
description: "Anthropic's Claude CLI tool".to_string(),
default_command: "claude".to_string(),
supported_capabilities: HashSet::from([
BindingType::AgentModel,
BindingType::Mcp,
BindingType::SubAgent,
]),
tags: vec!["claude".to_string(), "anthropic".to_string()],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn command_defaults_to_claude() {
let l = ClaudeLauncher::new(
"my-claude",
&serde_json::json!({}),
&crate::config::Config::default(),
);
assert_eq!(l.command(), "claude");
}
#[test]
fn command_uses_explicit_path_when_set() {
let l = ClaudeLauncher::new(
"my-claude",
&serde_json::json!({
"command_path": "/opt/bin/claude"
}),
&crate::config::Config::default(),
);
assert_eq!(l.command(), "/opt/bin/claude");
}
#[test]
fn validate_command_err_for_nonexistent_explicit_path() {
let l = ClaudeLauncher::new(
"my-claude",
&serde_json::json!({
"command_path": "/no/such/path/claude"
}),
&crate::config::Config::default(),
);
assert!(l.validate_command().is_err());
}
#[test]
fn validate_command_falls_back_to_path_for_bare_command_name() {
let l = ClaudeLauncher::new(
"my-claude",
&serde_json::json!({
"command_path": "ls"
}),
&crate::config::Config::default(),
);
assert!(l.validate_command().is_ok());
}
#[test]
fn metadata_name_is_claude_cli() {
let meta = ClaudeLauncher::metadata();
assert_eq!(meta.name, "Claude CLI");
assert_eq!(meta.default_command, "claude");
}
#[test]
fn config_schema_is_present() {
use crate::launchers::base::LauncherFactory;
let mut factory = LauncherFactory::new();
factory.register::<ClaudeLauncher>("claude");
let schema = factory.config_schema("claude").unwrap();
let props = schema.get("properties").and_then(|p| p.as_object());
assert!(props.is_some());
assert!(props.unwrap().contains_key("command_path"));
}
#[test]
fn metadata_supports_sub_agent_binding() {
let meta = ClaudeLauncher::metadata();
assert!(meta.supported_capabilities.contains(&BindingType::SubAgent));
}
#[tokio::test]
async fn env_overlay_includes_custom_headers() {
let mut headers = HashMap::new();
headers.insert(
"X-Custom-Header".to_string(),
crate::registry::Secret::from("value1"),
);
headers.insert(
"User-Agent".to_string(),
crate::registry::Secret::from("my-agent/1.0"),
);
let l = launcher_with(
Some(crate::capabilities::AgentModelBinding {
api_type: crate::providers::ApiType::Anthropic,
provider_name: "test".to_string(),
base_url: "http://test".to_string(),
model_name: "test-model".to_string(),
endpoint_path: "/v1/messages".to_string(),
api_key: Some(crate::registry::Secret::from("test-key")),
verify_ssl: true,
context_length: Some(4096),
custom_headers: Some(headers),
}),
vec![],
);
let overlay = l.env_overlay(&test_launch_context(false)).await.unwrap();
let headers_entry = overlay
.iter()
.find(|b| b.key == "ANTHROPIC_CUSTOM_HEADERS")
.expect("ANTHROPIC_CUSTOM_HEADERS should be set");
assert_eq!(
headers_entry.value,
"User-Agent: my-agent/1.0\nX-Custom-Header: value1"
);
}
#[tokio::test]
async fn env_overlay_omits_custom_headers_when_none_set() {
let l = launcher_with(
Some(crate::capabilities::AgentModelBinding {
api_type: crate::providers::ApiType::Anthropic,
provider_name: "test".to_string(),
base_url: "http://test".to_string(),
model_name: "test-model".to_string(),
endpoint_path: "/v1/messages".to_string(),
api_key: Some(crate::registry::Secret::from("test-key")),
verify_ssl: true,
context_length: Some(4096),
custom_headers: None,
}),
vec![],
);
let overlay = l.env_overlay(&test_launch_context(false)).await.unwrap();
let headers_entry = overlay.iter().find(|b| b.key == "ANTHROPIC_CUSTOM_HEADERS");
assert!(headers_entry.is_none());
}
#[tokio::test]
async fn env_overlay_omits_custom_headers_when_empty_map() {
let l = launcher_with(
Some(crate::capabilities::AgentModelBinding {
api_type: crate::providers::ApiType::Anthropic,
provider_name: "test".to_string(),
base_url: "http://test".to_string(),
model_name: "test-model".to_string(),
endpoint_path: "/v1/messages".to_string(),
api_key: Some(crate::registry::Secret::from("test-key")),
verify_ssl: true,
context_length: Some(4096),
custom_headers: Some(HashMap::new()),
}),
vec![],
);
let overlay = l.env_overlay(&test_launch_context(false)).await.unwrap();
let headers_entry = overlay.iter().find(|b| b.key == "ANTHROPIC_CUSTOM_HEADERS");
assert!(headers_entry.is_none());
}
#[test]
fn map_tool_name_covers_every_canonical_variant_and_formats_mcp_references() {
let l = ClaudeLauncher::new(
"my-claude",
&serde_json::json!({}),
&crate::config::Config::default(),
);
assert_eq!(
l.map_tool_name(&ToolName::FileRead),
Some("Read".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::FileWrite),
Some("Write".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::FileEdit),
Some("Edit".to_string())
);
assert_eq!(l.map_tool_name(&ToolName::Search), Some("Grep".to_string()));
assert_eq!(
l.map_tool_name(&ToolName::FileSearch),
Some("Glob".to_string())
);
assert_eq!(l.map_tool_name(&ToolName::Shell), Some("Bash".to_string()));
assert_eq!(
l.map_tool_name(&ToolName::WebFetch),
Some("WebFetch".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::WebSearch),
Some("WebSearch".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::Mcp {
server: "vision".to_string(),
tool: None,
}),
Some("mcp__vision".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::Mcp {
server: "vision".to_string(),
tool: Some("vlm_compare_images".to_string()),
}),
Some("mcp__vision__vlm_compare_images".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::Other("SomeRawClaudeTool".to_string())),
Some("SomeRawClaudeTool".to_string())
);
}
fn sub_agent_binding(
description: &str,
model_name: &str,
tools: Vec<ToolName>,
) -> SubAgentBinding {
SubAgentBinding {
description: description.to_string(),
prompt: "You are a helpful sub-agent.".to_string(),
tools,
model: crate::capabilities::AgentModelBinding {
api_type: crate::providers::ApiType::Anthropic,
provider_name: "my-ollama".to_string(),
base_url: "http://localhost:11434".to_string(),
model_name: model_name.to_string(),
endpoint_path: "/v1/messages".to_string(),
api_key: None,
verify_ssl: true,
context_length: Some(4096),
custom_headers: None,
},
known_type: None,
}
}
fn launcher_with(
bound_agent_model: Option<crate::capabilities::AgentModelBinding>,
bound_sub_agents: Vec<(String, SubAgentBinding)>,
) -> ClaudeLauncher {
let mut l = ClaudeLauncher::new(
"my-claude",
&serde_json::json!({}),
&crate::config::Config::default(),
);
l.bound_agent_model = bound_agent_model;
l.bound_sub_agents = bound_sub_agents;
l
}
fn test_launch_context(dry_run: bool) -> LaunchContext {
LaunchContext {
launcher_id: "my-claude".to_string(),
working_dir: std::env::current_dir().unwrap(),
base_env: std::collections::HashMap::new(),
dry_run,
}
}
#[test]
fn build_agents_json_includes_description_prompt_and_model_but_omits_empty_tools() {
let ui = crate::utils::ui::backends::plain::PlainOutput;
let l = launcher_with(
None,
vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "granite-3.1-8b-instruct", vec![]),
)],
);
let json: serde_json::Value = serde_json::from_str(&l.build_agents_json(&ui)).unwrap();
let entry = &json["reviewer"];
assert_eq!(entry["description"], "Reviews code");
assert_eq!(entry["prompt"], "You are a helpful sub-agent.");
assert_eq!(entry["model"], "granite-3.1-8b-instruct");
assert!(entry.get("tools").is_none());
}
#[test]
fn build_agents_json_includes_tools_when_present() {
let ui = crate::utils::ui::backends::plain::PlainOutput;
let l = launcher_with(
None,
vec![(
"reviewer".to_string(),
sub_agent_binding(
"Reviews code",
"granite-3.1-8b-instruct",
vec![ToolName::FileRead, ToolName::Search],
),
)],
);
let json: serde_json::Value = serde_json::from_str(&l.build_agents_json(&ui)).unwrap();
assert_eq!(
json["reviewer"]["tools"],
serde_json::json!(["Read", "Grep"])
);
}
#[test]
fn build_agents_json_covers_every_bound_sub_agent_by_instance_id() {
let ui = crate::utils::ui::backends::plain::PlainOutput;
let l = launcher_with(
None,
vec![
(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "model-a", vec![]),
),
(
"summarizer".to_string(),
sub_agent_binding("Summarizes text", "model-b", vec![]),
),
],
);
let json: serde_json::Value = serde_json::from_str(&l.build_agents_json(&ui)).unwrap();
assert_eq!(json.as_object().unwrap().len(), 2);
assert_eq!(json["reviewer"]["model"], "model-a");
assert_eq!(json["summarizer"]["model"], "model-b");
}
#[test]
fn wire_model_proxy_is_a_noop_without_a_handle_or_sub_agents() {
let l = launcher_with(None, vec![]);
let mut overlay = vec![];
l.wire_model_proxy(&test_launch_context(false), &mut overlay)
.unwrap();
assert!(overlay.is_empty());
}
#[tokio::test]
async fn wire_model_proxy_points_at_the_proxy_even_with_no_bound_model_or_sub_agents() {
let server = crate::proxy::ProxyServer::start().unwrap();
let mut l = launcher_with(None, vec![]);
l.model_proxy = Some(server.handle.clone());
let mut overlay = vec![];
l.wire_model_proxy(&test_launch_context(false), &mut overlay)
.unwrap();
assert_eq!(overlay.len(), 1);
assert_eq!(overlay[0].key, "ANTHROPIC_BASE_URL");
assert_eq!(overlay[0].value, server.handle.local_base_url);
server.shutdown().await;
}
#[test]
fn wire_model_proxy_sets_placeholder_under_dry_run_without_a_handle() {
let l = launcher_with(
None,
vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "granite-3.1-8b-instruct", vec![]),
)],
);
let mut overlay = vec![];
l.wire_model_proxy(&test_launch_context(true), &mut overlay)
.unwrap();
assert_eq!(overlay.len(), 1);
assert_eq!(overlay[0].key, "ANTHROPIC_BASE_URL");
assert_eq!(
overlay[0].value,
"<sub-agent router: not started under --dry-run>"
);
}
#[test]
fn wire_model_proxy_warns_and_leaves_overlay_untouched_without_a_handle_outside_dry_run() {
let l = launcher_with(
None,
vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "granite-3.1-8b-instruct", vec![]),
)],
);
let mut overlay = vec![];
l.wire_model_proxy(&test_launch_context(false), &mut overlay)
.unwrap();
assert!(overlay.is_empty());
}
#[tokio::test]
async fn wire_model_proxy_points_default_at_the_main_models_registered_route() {
async fn echo(
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> axum::response::Response {
use axum::response::IntoResponse;
let value: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let api_key = headers
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
axum::Json(serde_json::json!({ "model": value.get("model"), "x_api_key": api_key }))
.into_response()
}
async fn spawn_echo_server() -> std::net::SocketAddr {
let app = axum::Router::new().route("/v1/messages", axum::routing::post(echo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
addr
}
let sub_agent_addr = spawn_echo_server().await;
let main_addr = spawn_echo_server().await;
let server = crate::proxy::ProxyServer::start().unwrap();
server
.handle
.register_route(
"granite-3.1-8b-instruct".to_string(),
crate::proxy::UpstreamTarget {
base_url: format!("http://{sub_agent_addr}"),
verify_ssl: true,
auth: crate::proxy::UpstreamAuth::Inject(Some(crate::registry::Secret(
"sub-key".to_string(),
))),
},
"reviewer".to_string(),
)
.unwrap();
server
.handle
.register_route(
"main-model".to_string(),
crate::proxy::UpstreamTarget {
base_url: format!("http://{main_addr}"),
verify_ssl: true,
auth: crate::proxy::UpstreamAuth::Inject(Some(crate::registry::Secret(
"main-key".to_string(),
))),
},
"main-model".to_string(),
)
.unwrap();
let mut l = launcher_with(
Some(crate::capabilities::AgentModelBinding {
api_type: crate::providers::ApiType::Anthropic,
provider_name: "main".to_string(),
base_url: server.handle.local_base_url.clone(),
model_name: "main-model".to_string(),
endpoint_path: "/v1/messages".to_string(),
api_key: None,
verify_ssl: true,
context_length: Some(4096),
custom_headers: None,
}),
vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "granite-3.1-8b-instruct", vec![]),
)],
);
l.model_proxy = Some(server.handle.clone());
let mut overlay = vec![];
l.wire_model_proxy(&test_launch_context(false), &mut overlay)
.unwrap();
assert_eq!(overlay[0].key, "ANTHROPIC_BASE_URL");
assert_eq!(overlay[0].value, server.handle.local_base_url);
let client = reqwest::Client::new();
let sub_resp: serde_json::Value = client
.post(format!("{}/v1/messages", server.handle.local_base_url))
.json(&serde_json::json!({"model": "granite-3.1-8b-instruct"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(sub_resp["x_api_key"], "sub-key");
let main_resp: serde_json::Value = client
.post(format!("{}/v1/messages", server.handle.local_base_url))
.json(&serde_json::json!({"model": "some-other-internal-model"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(main_resp["x_api_key"], "main-key");
server.shutdown().await;
}
#[test]
fn set_env_binding_overwrites_existing_entry() {
let mut overlay = vec![EnvBinding {
key: "ANTHROPIC_BASE_URL".to_string(),
value: "http://original".to_string(),
}];
set_env_binding(
&mut overlay,
"ANTHROPIC_BASE_URL",
"http://router".to_string(),
);
assert_eq!(overlay.len(), 1);
assert_eq!(overlay[0].value, "http://router");
}
#[test]
fn set_env_binding_appends_when_absent() {
let mut overlay = vec![];
set_env_binding(
&mut overlay,
"ANTHROPIC_BASE_URL",
"http://router".to_string(),
);
assert_eq!(overlay.len(), 1);
assert_eq!(overlay[0].key, "ANTHROPIC_BASE_URL");
assert_eq!(overlay[0].value, "http://router");
}
struct FakeSubAgentCapability {
instance_id: String,
binding: SubAgentBinding,
}
impl crate::registry::Named for FakeSubAgentCapability {
fn instance_id(&self) -> &str {
&self.instance_id
}
}
#[async_trait]
impl Capability for FakeSubAgentCapability {
fn name(&self) -> &str {
"Fake Sub-Agent"
}
fn description(&self) -> &str {
"test double"
}
fn binding_types(&self) -> HashSet<BindingType> {
HashSet::from([BindingType::SubAgent])
}
async fn bind(
&self,
_request: crate::capabilities::BindingRequest,
) -> anyhow::Result<Binding> {
Ok(Binding::SubAgent(self.binding.clone()))
}
}
#[tokio::test]
async fn bind_capability_pushes_sub_agent_binding() {
let mut l = ClaudeLauncher::new(
"my-claude",
&serde_json::json!({}),
&crate::config::Config::default(),
);
let cap = FakeSubAgentCapability {
instance_id: "reviewer".to_string(),
binding: sub_agent_binding("Reviews code", "granite-3.1-8b-instruct", vec![]),
};
l.bind_capability(&cap).await.unwrap();
assert_eq!(l.bound_sub_agents.len(), 1);
assert_eq!(l.bound_sub_agents[0].0, "reviewer");
assert_eq!(
l.bound_sub_agents[0].1.model.model_name,
"granite-3.1-8b-instruct"
);
}
}