use std::collections::HashSet;
use std::path::{Path, PathBuf};
use alog::{MessageLevel, alog_channel, use_channel};
use anyhow::Context;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::capabilities::{
AgentModelBinding, Binding, BindingType, Capability, KnownSubAgent, McpBinding,
SubAgentBinding, ToolName,
};
use crate::launchers::base::{EnvBinding, LaunchContext, Launcher, LauncherMetadata, run_command};
use crate::launchers::shared::mcp_cli::mcp_binding_request;
use crate::providers::ApiType;
use crate::registry::ConfigConstructable;
use crate::utils::resolve_shell_command;
use crate::utils::ui::Ui;
use_channel!("OPNCD");
#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
pub struct OpenCodeLauncherConfig {
#[serde(default)]
pub command_path: Option<String>,
#[serde(default)]
pub provider_overrides: Option<serde_json::Value>,
}
pub struct OpenCodeLauncher {
instance_id: String,
config: OpenCodeLauncherConfig,
bound_agent_model: Option<AgentModelBinding>,
bound_mcp_bindings: Vec<(String, McpBinding)>,
bound_sub_agents: Vec<(String, SubAgentBinding)>,
}
impl ConfigConstructable for OpenCodeLauncher {
type Config = OpenCodeLauncherConfig;
fn new(
instance_id: &str,
cfg: &serde_json::Value,
_global_config: &crate::config::Config,
) -> Self {
let config: OpenCodeLauncherConfig =
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![],
}
}
}
impl crate::registry::Named for OpenCodeLauncher {
fn instance_id(&self) -> &str {
&self.instance_id
}
}
#[async_trait]
impl Launcher for OpenCodeLauncher {
fn name(&self) -> &str {
"OpenCode CLI"
}
fn command(&self) -> &str {
self.config.command_path.as_deref().unwrap_or("opencode")
}
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: ApiType::OpenAI,
},
);
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: ApiType::OpenAI,
},
);
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, "opencode")
}
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!("{server}_*"),
ToolName::Mcp {
server,
tool: Some(t),
} => format!("{server}_{t}"),
ToolName::Other(raw) => raw.clone(),
})
}
async fn env_overlay(&self, ctx: &LaunchContext) -> anyhow::Result<Vec<EnvBinding>> {
let mut overlay = vec![];
if self.bound_agent_model.is_some()
|| !self.bound_mcp_bindings.is_empty()
|| !self.bound_sub_agents.is_empty()
{
overlay.push(EnvBinding {
key: CONFIG_ENV.to_string(),
value: opencode_config_path(ctx)?.to_string_lossy().to_string(),
});
for (index, (binding, _)) in self.provider_groups().iter().enumerate() {
if let Some(api_key) = binding
.api_key
.as_ref()
.map(|api_key| api_key.0.clone())
.filter(|key| !key.is_empty())
{
overlay.push(EnvBinding {
key: provider_api_key_env(index),
value: api_key,
});
}
}
}
Ok(overlay)
}
async fn launch(
&self,
args: &[String],
ctx: &LaunchContext,
ui: &dyn Ui,
) -> anyhow::Result<std::process::ExitStatus> {
if self.bound_agent_model.is_some()
|| !self.bound_mcp_bindings.is_empty()
|| !self.bound_sub_agents.is_empty()
{
let mut providers = serde_json::Map::new();
for (index, (binding, model_names)) in self.provider_groups().iter().enumerate() {
let entry =
self.provider_entry(binding, model_names, &provider_api_key_env(index))?;
providers.insert(binding.provider_name.clone(), entry);
}
let agent = self.build_agent_config(ui);
let config = generate_config(
self.bound_agent_model.as_ref(),
providers,
agent,
&self.bound_mcp_bindings,
);
let config_path = opencode_config_path(ctx)?;
if ctx.dry_run {
ui.info(&format!(
"Would write OpenCode config to {}:",
config_path.display()
));
ui.info(&serde_json::to_string_pretty(&config)?);
} else {
write_opencode_config(&config_path, &config)?;
ui.info(&format!(
"Wrote OpenCode config to {}",
config_path.display()
));
}
}
let binary = self.validate_command()?;
let overlay = self.env_overlay(ctx).await?;
alog_channel!(MessageLevel::Debug2, "Env Overlay: {:#?}", overlay);
run_command(binary, &overlay, args, ctx, ui).await
}
}
impl HasOpenCodeLauncherMetadata for OpenCodeLauncher {
fn metadata() -> LauncherMetadata {
LauncherMetadata {
name: "OpenCode CLI".to_string(),
description: "OpenCode terminal coding agent".to_string(),
default_command: "opencode".to_string(),
supported_capabilities: HashSet::from([
BindingType::AgentModel,
BindingType::Mcp,
BindingType::SubAgent,
]),
tags: vec!["opencode".to_string(), "coding-agent".to_string()],
}
}
}
use crate::launchers::base::HasLauncherMetadata as HasOpenCodeLauncherMetadata;
const CONFIG_ENV: &str = "OPENCODE_CONFIG";
const API_KEY_ENV: &str = "GRANITE_CLI_OPENCODE_API_KEY";
const CONFIG_FILE: &str = "opencode.json";
impl OpenCodeLauncher {
fn provider_entry(
&self,
binding: &AgentModelBinding,
model_names: &[&str],
api_key_env: &str,
) -> anyhow::Result<serde_json::Value> {
let mut options = serde_json::json!({ "baseURL": opencode_base_url(binding) });
if binding
.api_key
.as_ref()
.is_some_and(|key| !key.0.is_empty())
{
options["apiKey"] = serde_json::Value::String(format!("{{env:{api_key_env}}}"));
}
if let Some(headers) = &binding.custom_headers {
let header_map: serde_json::Map<String, serde_json::Value> = headers
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.0.clone())))
.collect();
options["headers"] = serde_json::Value::Object(header_map);
}
let mut models = serde_json::Map::new();
for name in model_names {
models.insert(
(*name).to_string(),
serde_json::json!({
"name": name,
}),
);
}
let mut entry = serde_json::json!({
"npm": "@ai-sdk/openai-compatible",
"name": binding.provider_name,
"options": options,
"models": serde_json::Value::Object(models),
});
if let (Some(overrides), Some(target)) = (
self.config
.provider_overrides
.as_ref()
.and_then(serde_json::Value::as_object),
entry.as_object_mut(),
) {
for (key, value) in overrides {
target.insert(key.clone(), value.clone());
}
}
Ok(entry)
}
fn provider_groups(&self) -> Vec<(&AgentModelBinding, Vec<&str>)> {
fn add<'a>(
groups: &mut Vec<(&'a AgentModelBinding, Vec<&'a str>)>,
binding: &'a AgentModelBinding,
) {
if let Some((_, model_names)) = groups
.iter_mut()
.find(|(b, _)| b.provider_name == binding.provider_name)
{
if !model_names.contains(&binding.model_name.as_str()) {
model_names.push(&binding.model_name);
}
} else {
groups.push((binding, vec![binding.model_name.as_str()]));
}
}
let mut groups = Vec::new();
if let Some(binding) = &self.bound_agent_model {
add(&mut groups, binding);
}
for (_, sub_agent) in &self.bound_sub_agents {
add(&mut groups, &sub_agent.model);
}
groups
}
fn build_agent_config(&self, ui: &dyn Ui) -> 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,
"mode": "subagent",
"model": format!("{}/{}", binding.model.provider_name, binding.model.model_name),
});
if !binding.tools.is_empty() {
let mut tools = serde_json::Map::new();
tools.insert("*".to_string(), serde_json::Value::Bool(false));
for tool in &binding.tools {
match self.map_tool_name(tool) {
Some(mapped) => {
tools.insert(mapped, serde_json::Value::Bool(true));
}
None => ui.warn(&format!(
"sub-agent '{name}': tool {tool:?} has no mapping for the opencode launcher, skipping"
)),
}
}
entry["tools"] = serde_json::Value::Object(tools);
}
let mapped_name = match binding.known_type {
Some(KnownSubAgent::Explore) => "explore".to_string(),
Some(KnownSubAgent::Plan) => "plan".to_string(),
_ => name.clone(),
};
(mapped_name, entry)
})
.collect()
}
}
fn provider_api_key_env(index: usize) -> String {
if index == 0 {
API_KEY_ENV.to_string()
} else {
format!("{API_KEY_ENV}_{index}")
}
}
fn opencode_base_url(binding: &AgentModelBinding) -> String {
let root = binding.base_url.trim_end_matches('/');
let prefix = binding
.endpoint_path
.strip_suffix("/chat/completions")
.unwrap_or("");
format!("{root}{prefix}")
}
fn opencode_config_path(ctx: &LaunchContext) -> anyhow::Result<PathBuf> {
Ok(crate::config::Config::launcher_state_dir(&ctx.launcher_id)?.join(CONFIG_FILE))
}
fn generate_config(
binding: Option<&AgentModelBinding>,
providers: serde_json::Map<String, serde_json::Value>,
agent: serde_json::Map<String, serde_json::Value>,
mcp_bindings: &[(String, McpBinding)],
) -> serde_json::Value {
let mut config = serde_json::json!({ "$schema": "https://opencode.ai/config.json" });
if let Some(binding) = binding {
config["model"] =
serde_json::Value::String(format!("{}/{}", binding.provider_name, binding.model_name));
}
if !providers.is_empty() {
config["provider"] = serde_json::Value::Object(providers);
}
if !agent.is_empty() {
config["agent"] = serde_json::Value::Object(agent);
}
if !mcp_bindings.is_empty() {
let mut mcp = serde_json::Map::new();
for (name, binding) in mcp_bindings {
mcp.insert(name.clone(), {
match binding {
McpBinding::Stdio { command, args, env } => {
let mut full_command = vec![command.clone()];
full_command.extend(args.iter().cloned());
serde_json::json!({
"type": "local",
"command": full_command,
"environment": env,
})
}
McpBinding::Http { url, headers } | McpBinding::Sse { url, headers } => {
serde_json::json!({
"type": "remote",
"url": url,
"headers": headers,
})
}
}
});
}
config["mcp"] = serde_json::Value::Object(mcp);
}
config
}
fn write_opencode_config(path: &Path, config: &serde_json::Value) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let mut content = serde_json::to_string_pretty(config)?;
content.push('\n');
std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::{Named, Secret};
use crate::utils::ui::base::tests::CaptureUi;
fn launcher(cfg: serde_json::Value) -> OpenCodeLauncher {
OpenCodeLauncher::new("opencode", &cfg, &crate::config::Config::default())
}
fn binding() -> AgentModelBinding {
AgentModelBinding {
api_type: ApiType::OpenAI,
provider_name: "my-ollama".to_string(),
base_url: "http://localhost:11434".to_string(),
model_name: "granite4.1:8b".to_string(),
endpoint_path: "/v1/chat/completions".to_string(),
api_key: None,
verify_ssl: true,
context_length: Some(131072),
custom_headers: None,
}
}
fn bound(cfg: serde_json::Value, binding: AgentModelBinding) -> OpenCodeLauncher {
let mut l = launcher(cfg);
l.bound_agent_model = Some(binding);
l
}
fn ctx(dry_run: bool) -> LaunchContext {
LaunchContext {
launcher_id: "opencode".to_string(),
working_dir: PathBuf::from("/tmp"),
base_env: std::collections::HashMap::new(),
dry_run,
}
}
#[test]
fn command_defaults_to_opencode() {
assert_eq!(launcher(serde_json::json!({})).command(), "opencode");
}
#[test]
fn command_uses_explicit_path_when_set() {
let l = launcher(serde_json::json!({ "command_path": "/opt/bin/opencode" }));
assert_eq!(l.command(), "/opt/bin/opencode");
}
#[test]
fn validate_command_err_for_nonexistent_explicit_path() {
let l = launcher(serde_json::json!({ "command_path": "/no/such/path/opencode" }));
assert!(l.validate_command().is_err());
}
#[test]
fn validate_command_falls_back_to_path_for_bare_command_name() {
let l = launcher(serde_json::json!({ "command_path": "ls" }));
assert!(l.validate_command().is_ok());
}
#[test]
fn metadata_name_is_opencode_cli() {
let meta = OpenCodeLauncher::metadata();
assert_eq!(meta.name, "OpenCode CLI");
assert_eq!(meta.default_command, "opencode");
assert!(
meta.supported_capabilities
.contains(&BindingType::AgentModel)
);
}
#[test]
fn metadata_supports_sub_agent_binding() {
let meta = OpenCodeLauncher::metadata();
assert!(meta.supported_capabilities.contains(&BindingType::SubAgent));
}
#[test]
fn instance_id_round_trips_from_construction() {
let l = OpenCodeLauncher::new(
"opencode-local",
&serde_json::json!({}),
&crate::config::Config::default(),
);
assert_eq!(l.instance_id(), "opencode-local");
}
#[test]
fn config_schema_exposes_only_command_path_and_overrides() {
use crate::launchers::base::LauncherFactory;
let mut factory = LauncherFactory::new();
factory.register::<OpenCodeLauncher>("opencode");
let schema = factory.config_schema("opencode").unwrap();
let props = schema
.get("properties")
.and_then(|p| p.as_object())
.unwrap();
assert!(props.contains_key("command_path"));
assert!(props.contains_key("provider_overrides"));
assert!(!props.contains_key("provider_name"));
}
#[test]
fn provider_entry_describes_bound_model() {
let b = binding();
let entry = launcher(serde_json::json!({}))
.provider_entry(&b, &[b.model_name.as_str()], API_KEY_ENV)
.unwrap();
assert_eq!(entry["npm"], "@ai-sdk/openai-compatible");
assert_eq!(entry["options"]["baseURL"], "http://localhost:11434/v1");
assert_eq!(entry["models"]["granite4.1:8b"]["name"], "granite4.1:8b");
assert!(entry["models"]["granite4.1:8b"].get("limit").is_none());
assert!(entry["options"].get("apiKey").is_none());
}
#[test]
fn provider_entry_describes_multiple_models_for_one_provider() {
let b = binding();
let entry = launcher(serde_json::json!({}))
.provider_entry(&b, &["granite4.1:8b", "granite4.1:3b"], API_KEY_ENV)
.unwrap();
assert_eq!(entry["models"]["granite4.1:8b"]["name"], "granite4.1:8b");
assert_eq!(entry["models"]["granite4.1:3b"]["name"], "granite4.1:3b");
}
#[test]
fn provider_entry_interpolates_env_when_key_present() {
let b = AgentModelBinding {
api_key: Some(Secret::from("sk-test")),
..binding()
};
let entry = launcher(serde_json::json!({}))
.provider_entry(&b, &[b.model_name.as_str()], API_KEY_ENV)
.unwrap();
assert_eq!(
entry["options"]["apiKey"],
"{env:GRANITE_CLI_OPENCODE_API_KEY}"
);
}
#[test]
fn provider_entry_uses_the_given_api_key_env_name() {
let b = AgentModelBinding {
api_key: Some(Secret::from("sk-test")),
..binding()
};
let entry = launcher(serde_json::json!({}))
.provider_entry(
&b,
&[b.model_name.as_str()],
"GRANITE_CLI_OPENCODE_API_KEY_1",
)
.unwrap();
assert_eq!(
entry["options"]["apiKey"],
"{env:GRANITE_CLI_OPENCODE_API_KEY_1}"
);
}
#[test]
fn provider_entry_omits_api_key_for_empty_secret() {
let b = AgentModelBinding {
api_key: Some(Secret::from("")),
..binding()
};
let entry = launcher(serde_json::json!({}))
.provider_entry(&b, &[b.model_name.as_str()], API_KEY_ENV)
.unwrap();
assert!(entry["options"].get("apiKey").is_none());
}
#[test]
fn provider_entry_merges_overrides() {
let l = launcher(serde_json::json!({
"provider_overrides": { "headers": { "X-Custom": "1" } }
}));
let b = binding();
let entry = l
.provider_entry(&b, &[b.model_name.as_str()], API_KEY_ENV)
.unwrap();
assert_eq!(entry["headers"]["X-Custom"], "1");
assert_eq!(entry["options"]["baseURL"], "http://localhost:11434/v1");
}
#[test]
fn provider_entry_overrides_win_on_conflict() {
let l = launcher(serde_json::json!({
"provider_overrides": { "npm": "@ai-sdk/openai" }
}));
let b = binding();
let entry = l
.provider_entry(&b, &[b.model_name.as_str()], API_KEY_ENV)
.unwrap();
assert_eq!(entry["npm"], "@ai-sdk/openai");
}
#[test]
fn provider_entry_includes_custom_headers_when_present() {
let mut headers = std::collections::HashMap::new();
headers.insert(
"Helicone-Cache-Enabled".to_string(),
Secret("true".to_string()),
);
headers.insert(
"Helicone-User-Id".to_string(),
Secret("opencode".to_string()),
);
let b = AgentModelBinding {
custom_headers: Some(headers),
..binding()
};
let entry = launcher(serde_json::json!({}))
.provider_entry(&b, &[b.model_name.as_str()], API_KEY_ENV)
.unwrap();
assert_eq!(
entry["options"]["headers"]["Helicone-Cache-Enabled"],
"true"
);
assert_eq!(entry["options"]["headers"]["Helicone-User-Id"], "opencode");
}
#[test]
fn provider_api_key_env_keeps_unsuffixed_name_at_index_zero() {
assert_eq!(provider_api_key_env(0), "GRANITE_CLI_OPENCODE_API_KEY");
}
#[test]
fn provider_api_key_env_suffixes_by_index_beyond_zero() {
assert_eq!(provider_api_key_env(1), "GRANITE_CLI_OPENCODE_API_KEY_1");
assert_eq!(provider_api_key_env(2), "GRANITE_CLI_OPENCODE_API_KEY_2");
}
fn sub_agent_binding(
description: &str,
provider_name: &str,
model_name: &str,
tools: Vec<ToolName>,
) -> SubAgentBinding {
SubAgentBinding {
description: description.to_string(),
prompt: "You are a helpful sub-agent.".to_string(),
tools,
model: AgentModelBinding {
provider_name: provider_name.to_string(),
model_name: model_name.to_string(),
..binding()
},
known_type: None,
}
}
#[test]
fn provider_groups_is_empty_with_nothing_bound() {
let l = launcher(serde_json::json!({}));
assert!(l.provider_groups().is_empty());
}
#[test]
fn provider_groups_includes_main_model_first() {
let mut l = launcher(serde_json::json!({}));
l.bound_agent_model = Some(binding());
let groups = l.provider_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].0.provider_name, "my-ollama");
assert_eq!(groups[0].1, vec!["granite4.1:8b"]);
}
#[test]
fn provider_groups_gives_a_distinct_provider_its_own_group() {
let mut l = launcher(serde_json::json!({}));
l.bound_agent_model = Some(binding());
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "other-provider", "other-model", vec![]),
)];
let groups = l.provider_groups();
assert_eq!(groups.len(), 2);
assert_eq!(groups[1].0.provider_name, "other-provider");
assert_eq!(groups[1].1, vec!["other-model"]);
}
#[test]
fn provider_groups_merges_model_names_sharing_a_provider() {
let mut l = launcher(serde_json::json!({}));
l.bound_agent_model = Some(binding());
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "my-ollama", "granite4.1:3b", vec![]),
)];
let groups = l.provider_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].1, vec!["granite4.1:8b", "granite4.1:3b"]);
}
#[test]
fn provider_groups_dedupes_identical_model_name_for_shared_provider() {
let mut l = launcher(serde_json::json!({}));
l.bound_agent_model = Some(binding());
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "my-ollama", "granite4.1:8b", vec![]),
)];
let groups = l.provider_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].1, vec!["granite4.1:8b"]);
}
#[test]
fn map_tool_name_covers_every_canonical_variant_and_formats_mcp_references() {
let l = launcher(serde_json::json!({}));
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("vision_*".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::Mcp {
server: "vision".to_string(),
tool: Some("vlm_compare_images".to_string()),
}),
Some("vision_vlm_compare_images".to_string())
);
assert_eq!(
l.map_tool_name(&ToolName::Other("SomeRawTool".to_string())),
Some("SomeRawTool".to_string())
);
}
#[test]
fn build_agent_config_includes_description_prompt_and_model_but_omits_empty_tools() {
let mut l = launcher(serde_json::json!({}));
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "my-ollama", "granite4.1:8b", vec![]),
)];
let ui = CaptureUi::default();
let agent = l.build_agent_config(&ui);
let entry = &agent["reviewer"];
assert_eq!(entry["description"], "Reviews code");
assert_eq!(entry["prompt"], "You are a helpful sub-agent.");
assert_eq!(entry["model"], "my-ollama/granite4.1:8b");
assert!(entry.get("tools").is_none());
}
#[test]
fn build_agent_config_denies_by_default_and_allows_only_listed_tools() {
let mut l = launcher(serde_json::json!({}));
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding(
"Reviews code",
"my-ollama",
"granite4.1:8b",
vec![ToolName::FileRead, ToolName::Search],
),
)];
let ui = CaptureUi::default();
let agent = l.build_agent_config(&ui);
assert_eq!(
agent["reviewer"]["tools"],
serde_json::json!({ "*": false, "read": true, "grep": true })
);
}
#[test]
fn build_agent_config_covers_every_bound_sub_agent_by_instance_id() {
let mut l = launcher(serde_json::json!({}));
l.bound_sub_agents = vec![
(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "my-ollama", "model-a", vec![]),
),
(
"summarizer".to_string(),
sub_agent_binding("Summarizes text", "my-ollama", "model-b", vec![]),
),
];
let ui = CaptureUi::default();
let agent = l.build_agent_config(&ui);
assert_eq!(agent.len(), 2);
assert_eq!(agent["reviewer"]["model"], "my-ollama/model-a");
assert_eq!(agent["summarizer"]["model"], "my-ollama/model-b");
}
#[test]
fn build_agent_config_maps_known_types_onto_opencodes_own_builtin_agent_names() {
let mut l = launcher(serde_json::json!({}));
l.bound_sub_agents = vec![(
"my-explorer".to_string(),
SubAgentBinding {
known_type: Some(KnownSubAgent::Explore),
..sub_agent_binding("Explores code", "my-ollama", "granite4.1:8b", vec![])
},
)];
let ui = CaptureUi::default();
let agent = l.build_agent_config(&ui);
assert!(agent.contains_key("explore"));
assert!(!agent.contains_key("my-explorer"));
}
#[test]
fn base_url_keeps_version_prefix_and_drops_operation() {
assert_eq!(opencode_base_url(&binding()), "http://localhost:11434/v1");
}
#[test]
fn base_url_trims_trailing_slash_from_provider_url() {
let b = AgentModelBinding {
base_url: "http://localhost:1234/".to_string(),
..binding()
};
assert_eq!(opencode_base_url(&b), "http://localhost:1234/v1");
}
#[test]
fn generate_config_nests_entry_under_provider_name_and_sets_default_model() {
let mut providers = serde_json::Map::new();
providers.insert(
"my-ollama".to_string(),
serde_json::json!({ "npm": "@ai-sdk/openai-compatible" }),
);
let config = generate_config(Some(&binding()), providers, serde_json::Map::new(), &[]);
assert_eq!(config["$schema"], "https://opencode.ai/config.json");
assert_eq!(config["model"], "my-ollama/granite4.1:8b");
assert_eq!(
config["provider"]["my-ollama"]["npm"],
"@ai-sdk/openai-compatible"
);
}
#[test]
fn generate_config_writes_mcp_block_without_a_model_binding() {
let mcp_binding = McpBinding::Http {
url: "http://127.0.0.1:9999".to_string(),
headers: Default::default(),
};
let config = generate_config(
None,
serde_json::Map::new(),
serde_json::Map::new(),
&[("vision".to_string(), mcp_binding)],
);
assert!(config.get("model").is_none());
assert!(config.get("provider").is_none());
assert_eq!(config["mcp"]["vision"]["type"], "remote");
assert_eq!(config["mcp"]["vision"]["url"], "http://127.0.0.1:9999");
}
#[test]
fn generate_config_writes_agent_block_when_sub_agents_present() {
let mut agent = serde_json::Map::new();
agent.insert(
"reviewer".to_string(),
serde_json::json!({ "description": "Reviews code" }),
);
let config = generate_config(None, serde_json::Map::new(), agent, &[]);
assert!(config.get("model").is_none());
assert_eq!(config["agent"]["reviewer"]["description"], "Reviews code");
}
#[test]
fn generate_config_omits_agent_key_when_no_sub_agents_bound() {
let config = generate_config(
Some(&binding()),
serde_json::Map::new(),
serde_json::Map::new(),
&[],
);
assert!(config.get("agent").is_none());
}
#[tokio::test]
async fn env_overlay_is_empty_without_a_binding() {
let overlay = launcher(serde_json::json!({}))
.env_overlay(&ctx(false))
.await
.unwrap();
assert!(overlay.is_empty());
}
#[tokio::test]
async fn env_overlay_redirects_config_and_exports_api_key() {
let b = AgentModelBinding {
api_key: Some(Secret::from("sk-test")),
..binding()
};
let overlay = bound(serde_json::json!({}), b)
.env_overlay(&ctx(false))
.await
.unwrap();
let config = overlay
.iter()
.find(|b| b.key == "OPENCODE_CONFIG")
.expect("config redirect");
assert!(
Path::new(&config.value).ends_with(
Path::new("launcher-state")
.join("opencode")
.join("opencode.json")
),
"{}",
config.value
);
let key = overlay
.iter()
.find(|b| b.key == "GRANITE_CLI_OPENCODE_API_KEY")
.expect("api key");
assert_eq!(key.value, "sk-test");
}
#[tokio::test]
async fn env_overlay_omits_api_key_when_provider_has_none() {
let overlay = bound(serde_json::json!({}), binding())
.env_overlay(&ctx(false))
.await
.unwrap();
assert!(
!overlay
.iter()
.any(|b| b.key == "GRANITE_CLI_OPENCODE_API_KEY")
);
}
#[tokio::test]
async fn env_overlay_exports_one_api_key_per_distinct_provider_when_sub_agents_present() {
let main = AgentModelBinding {
api_key: Some(Secret::from("main-key")),
..binding()
};
let mut l = bound(serde_json::json!({}), main);
l.bound_sub_agents = vec![(
"reviewer".to_string(),
SubAgentBinding {
model: AgentModelBinding {
provider_name: "other-provider".to_string(),
model_name: "other-model".to_string(),
api_key: Some(Secret::from("sub-key")),
..binding()
},
..sub_agent_binding("Reviews code", "other-provider", "other-model", vec![])
},
)];
let overlay = l.env_overlay(&ctx(false)).await.unwrap();
let main_key = overlay
.iter()
.find(|b| b.key == "GRANITE_CLI_OPENCODE_API_KEY")
.expect("main model's api key");
assert_eq!(main_key.value, "main-key");
let sub_key = overlay
.iter()
.find(|b| b.key == "GRANITE_CLI_OPENCODE_API_KEY_1")
.expect("sub-agent's own api key, on its own suffixed env var");
assert_eq!(sub_key.value, "sub-key");
}
#[tokio::test]
async fn env_overlay_redirects_config_when_only_a_sub_agent_is_bound() {
let mut l = launcher(serde_json::json!({}));
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "my-ollama", "granite4.1:8b", vec![]),
)];
let overlay = l.env_overlay(&ctx(false)).await.unwrap();
assert!(
overlay.iter().any(|b| b.key == "OPENCODE_CONFIG"),
"a sub-agent alone (no main model, no MCP) must still redirect OPENCODE_CONFIG"
);
}
#[tokio::test]
async fn dry_run_launch_reports_without_writing_anything() {
let state_dir = crate::config::Config::launcher_state_dir("opencode").unwrap();
let existed_before = state_dir.exists();
let l = bound(serde_json::json!({ "command_path": "ls" }), binding());
let ui = CaptureUi::default();
let status = l
.launch(&["--help".to_string()], &ctx(true), &ui)
.await
.unwrap();
assert!(status.success());
let infos = ui.infos.borrow();
assert!(
infos
.iter()
.any(|m| m.contains("Would write OpenCode config")),
"expected a dry-run notice, got {infos:?}"
);
assert!(
infos
.iter()
.any(|m| m.contains(r#""model": "my-ollama/granite4.1:8b""#)),
"expected the generated config to select the model, got {infos:?}"
);
assert!(
infos.iter().any(|m| m.contains("args: --help")),
"expected caller args to pass through unmodified, got {infos:?}"
);
assert_eq!(
state_dir.exists(),
existed_before,
"dry run must not create {}",
state_dir.display()
);
}
#[tokio::test]
async fn launch_without_binding_passes_args_through_unchanged() {
let l = launcher(serde_json::json!({ "command_path": "ls" }));
let ui = CaptureUi::default();
l.launch(&["--version".to_string()], &ctx(true), &ui)
.await
.unwrap();
let infos = ui.infos.borrow();
assert!(infos.iter().any(|m| m.contains("args: --version")));
assert!(
!infos
.iter()
.any(|m| m.contains("Would write OpenCode config"))
);
assert!(!infos.iter().any(|m| m.contains(CONFIG_ENV)));
}
#[tokio::test]
async fn dry_run_launch_with_sub_agents_writes_agent_and_provider_blocks() {
let mut l = bound(serde_json::json!({ "command_path": "ls" }), binding());
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding(
"Reviews code",
"other-provider",
"other-model",
vec![ToolName::FileRead],
),
)];
let ui = CaptureUi::default();
l.launch(&[], &ctx(true), &ui).await.unwrap();
let infos = ui.infos.borrow();
let dump = infos.join("\n");
assert!(dump.contains(r#""reviewer""#), "{dump}");
assert!(dump.contains(r#""my-ollama/granite4.1:8b""#), "{dump}");
assert!(dump.contains(r#""other-provider""#), "{dump}");
assert!(dump.contains(r#""my-ollama": {"#), "{dump}");
assert!(dump.contains(r#""other-provider": {"#), "{dump}");
}
#[tokio::test]
async fn dry_run_launch_with_only_a_sub_agent_still_writes_a_config() {
let mut l = launcher(serde_json::json!({ "command_path": "ls" }));
l.bound_sub_agents = vec![(
"reviewer".to_string(),
sub_agent_binding("Reviews code", "my-ollama", "granite4.1:8b", vec![]),
)];
let ui = CaptureUi::default();
l.launch(&[], &ctx(true), &ui).await.unwrap();
let infos = ui.infos.borrow();
assert!(
infos
.iter()
.any(|m| m.contains("Would write OpenCode config")),
"a sub-agent alone (no main model, no MCP) must still trigger config generation, got {infos:?}"
);
}
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 = launcher(serde_json::json!({}));
let cap = FakeSubAgentCapability {
instance_id: "reviewer".to_string(),
binding: sub_agent_binding("Reviews code", "my-ollama", "granite4.1:8b", 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, "granite4.1:8b");
}
}